Exemplo n.º 1
0
 def _make_new(self, player0_id, player1_id, robot_level=0, prefs=None):
     """ Initialize a new, fresh game """
     self._preferences = prefs
     # If either player0_id or player1_id is None, this is a human-vs-autoplayer game
     self.player_ids = [player0_id, player1_id]
     self.state = State(drawtiles=True, tileset=self.tileset)
     self.initial_racks[0] = self.state.rack(0)
     self.initial_racks[1] = self.state.rack(1)
     self.robot_level = robot_level
     self.timestamp = self.ts_last_move = datetime.utcnow()
Exemplo n.º 2
0
 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)
Exemplo n.º 3
0
 def _make_new(self, player0_id, player1_id, robot_level = 0, prefs = None):
     """ Initialize a new, fresh game """
     # If either player0_id or player1_id is None, this is a human-vs-autoplayer game
     self.player_ids = [player0_id, player1_id]
     self.state = State(drawtiles = True)
     self.initial_racks[0] = self.state.rack(0)
     self.initial_racks[1] = self.state.rack(1)
     self.robot_level = robot_level
     self.timestamp = self.ts_last_move = datetime.utcnow()
     self._preferences = prefs
Exemplo n.º 4
0
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)))
Exemplo n.º 5
0
def test_game(players, silent):
    """ Go through a whole game by pitting two AutoPlayers against each other """
    # The players parameter is a list of tuples: (playername, constructorfunc)
    # where constructorfunc accepts a State parameter and returns a freshly
    # created AutoPlayer (or subclass thereof) that will generate moves
    # on behalf of the player.

    # Initial, empty game state
    state = State(tileset = NewTileSet, drawtiles = True)

    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, players[ix][0])

    if not silent:
        print(state.__str__()) # This works in Python 2 and 3

    # Generate a sequence of moves, switching player sides automatically

    t0 = time.time()

    while not state.is_game_over():

        # Call the appropriate player creation function
        apl = players[state.player_to_move()][1](state)

        g0 = time.time()
        move = apl.generate_move()
        g1 = time.time()

        legal = state.check_legality(move)
        if legal != Error.LEGAL:
            # Oops: the autoplayer generated an illegal move
            print(u"Play is not legal, code {0}".format(Error.errortext(legal)))
            return

        if not silent:
            print(u"Play {0} scores {1} points ({2:.2f} seconds)".format(move, state.score(move), g1 - g0))

        # Apply the move to the state and switch players
        state.apply_move(move)

        if not silent:
            print(state.__str__())

    # Tally the tiles left and calculate the final score
    state.finalize_score()
    p0, p1 = state.scores()
    t1 = time.time()

    if not silent:
        print(u"Game over, final score {4} {0} : {5} {1} after {2} moves ({3:.2f} seconds)".format(p0, p1,
            state.num_moves(), t1 - t0, state.player_name(0), state.player_name(1)))

    return state.scores()
Exemplo n.º 6
0
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)))
Exemplo n.º 7
0
def test_game(players, silent):
    """ Go through a whole game by pitting two AutoPlayers against each other """
    # The players parameter is a list of tuples: (playername, constructorfunc)
    # where constructorfunc accepts a State parameter and returns a freshly
    # created AutoPlayer (or subclass thereof) that will generate moves
    # on behalf of the player.

    # Initial, empty game state
    state = State(tileset=NewTileSet, drawtiles=True)

    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, players[ix][0])

    if not silent:
        print(state.__str__())  # This works in Python 2 and 3

    # Generate a sequence of moves, switching player sides automatically

    t0 = time.time()

    while not state.is_game_over():

        # Call the appropriate player creation function
        apl = players[state.player_to_move()][1](state)

        g0 = time.time()
        move = apl.generate_move()
        g1 = time.time()

        legal = state.check_legality(move)
        if legal != Error.LEGAL:
            # Oops: the autoplayer generated an illegal move
            print(u"Play is not legal, code {0}".format(
                Error.errortext(legal)))
            return

        if not silent:
            print(u"Play {0} scores {1} points ({2:.2f} seconds)".format(
                move, state.score(move), g1 - g0))

        # Apply the move to the state and switch players
        state.apply_move(move)

        if not silent:
            print(state.__str__())

    # Tally the tiles left and calculate the final score
    state.finalize_score()
    p0, p1 = state.scores()
    t1 = time.time()

    if not silent:
        print(
            u"Game over, final score {4} {0} : {5} {1} after {2} moves ({3:.2f} seconds)"
            .format(p0, p1, state.num_moves(), t1 - t0, state.player_name(0),
                    state.player_name(1)))

    return state.scores()
Exemplo n.º 8
0
 def state_after_move(self, move_number):
     """ Return a game state after the indicated move, 0=beginning state """
     # Initialize a fresh state object
     s = State(drawtiles = False)
     # Set up the initial state
     for ix in range(2):
         s.set_player_name(ix, self.state.player_name(ix))
         if self.initial_racks[ix] is None:
             # Load the current rack rather than nothing
             s.set_rack(ix, self.state.rack(ix))
         else:
             # Load the initial rack
             s.set_rack(ix, self.initial_racks[ix])
     # Apply the moves up to the state point
     for m in self.moves[0 : move_number]:
         s.apply_move(m.move, shallow = True) # Shallow apply
         if m.rack is not None:
             s.set_rack(m.player, m.rack)
     s.recalc_bag()
     return s
Exemplo n.º 9
0
class Game:

    """ A wrapper class for a particular game that is in process
        or completed. Contains inter alia a State instance.
    """

    # The available autoplayers (robots)
    AUTOPLAYERS = [
        (u"Fullsterkur",
            u"Velur stigahæsta leik í hverri stöðu", 0),
        (u"Miðlungur",
            u"Velur af handahófi einn af 8 stigahæstu leikjum í hverri stöðu", 8),
        (u"Amlóði",
            u"Forðast sjaldgæf orð og velur úr 20 leikjum sem koma til álita", 15)
        ]

    # The default nickname to display if a player has an unreadable nick
    # (for instance a default Google nick with a https:// prefix)
    UNDEFINED_NAME = u"[Ónefndur]"

    # The maximum overtime in a game, after which a player automatically loses
    MAX_OVERTIME = 10 * 60.0 # 10 minutes, in seconds

    # After this number of days the game becomes overdue and the
    # waiting player can force the tardy opponent to resign
    OVERDUE_DAYS = 14

    _lock = threading.Lock()

    def __init__(self, uuid = None):
        # Unique id of the game
        self.uuid = uuid
        # The start time of the game
        self.timestamp = None
        # The user ids of the players (None if autoplayer)
        # Player 0 is the one that begins the game
        self.player_ids = [None, None]
        # The current game state
        self.state = None
        # The ability level of the autoplayer (0 = strongest)
        self.robot_level = 0
        # The last move made by the remote player
        self.last_move = None
        # The timestamp of the last move made in the game
        self.ts_last_move = None
        # History of moves in this game so far, as a list of MoveTuple namedtuples
        self.moves = []
        # Initial rack contents
        self.initial_racks = [None, None]
        # Preferences (such as time limit, alternative bag or board, etc.)
        self._preferences = None
        # Cache of game over state (becomes True when the game is definitely over)
        self._game_over = None

    def _make_new(self, player0_id, player1_id, robot_level = 0, prefs = None):
        """ Initialize a new, fresh game """
        # If either player0_id or player1_id is None, this is a human-vs-autoplayer game
        self.player_ids = [player0_id, player1_id]
        self.state = State(drawtiles = True)
        self.initial_racks[0] = self.state.rack(0)
        self.initial_racks[1] = self.state.rack(1)
        self.robot_level = robot_level
        self.timestamp = self.ts_last_move = datetime.utcnow()
        self._preferences = prefs

    @classmethod
    def new(cls, player0_id, player1_id, robot_level = 0, prefs = None):
        """ Start and initialize a new game """
        game = cls(Unique.id()) # Assign a new unique id to the game
        if randint(0, 1) == 1:
            # Randomize which player starts the game
            player0_id, player1_id = player1_id, player0_id
        game._make_new(player0_id, player1_id, robot_level, prefs)
        # If AutoPlayer is first to move, generate the first move
        if game.player_id_to_move() is None:
            game.autoplayer_move()
        # Store the new game in persistent storage
        game.store()
        return game

    @classmethod
    def load(cls, uuid, use_cache = True):
        """ Load an already existing game from persistent storage """
        with Game._lock:
            # Ensure that the game load does not introduce race conditions
            return cls._load_locked(uuid, use_cache)

    def store(self):
        """ Store the game state in persistent storage """
        # Avoid race conditions by securing the lock before storing
        with Game._lock:
            self._store_locked()

    @classmethod
    def _load_locked(cls, uuid, use_cache = True):
        """ Load an existing game from cache or persistent storage under lock """

        gm = GameModel.fetch(uuid, use_cache)
        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)

        # Set the timestamps
        game.timestamp = gm.timestamp
        game.ts_last_move = gm.ts_last_move
        if game.ts_last_move is None:
            # If no last move timestamp, default to the start of the game
            game.ts_last_move = game.timestamp

        # Initialize the preferences
        game._preferences = gm.prefs

        # Initialize a fresh, empty state with no tiles drawn into the racks
        game.state = State(drawtiles = False)

        # A player_id of None means that the player is an autoplayer (robot)
        game.player_ids[0] = None if gm.player0 is None else gm.player0.id()
        game.player_ids[1] = None if gm.player1 is None else gm.player1.id()

        game.robot_level = gm.robot_level

        # Load the initial racks
        game.initial_racks[0] = gm.irack0
        game.initial_racks[1] = gm.irack1

        # Load the current racks
        game.state.set_rack(0, gm.rack0)
        game.state.set_rack(1, gm.rack1)

        # Process the moves
        player = 0
        # mx = 0 # Move counter for debugging/logging
        for mm in gm.moves:

            # mx += 1
            # logging.info(u"Game move {0} tiles '{3}' score is {1}:{2}".format(mx, game.state._scores[0], game.state._scores[1], mm.tiles).encode("latin-1"))

            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
                if mm.tiles is not None:
                    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(MoveTuple(player, m, mm.rack, mm.timestamp))
                player = 1 - player

        # Find out what tiles are now in the bag
        game.state.recalc_bag()

        # Account for the final tiles in the rack and overtime, if any
        if game.is_over():
            game.finalize_score()
            if not gm.over:
                # The game was not marked as over when we loaded it from
                # the datastore, but it is over now. One of the players must
                # have lost on overtime. We need to update the persistent state.
                game._store_locked()

        return game

    def _store_locked(self):
        """ Store the game after having acquired the object lock """

        assert self.uuid is not None

        gm = GameModel(id = self.uuid)
        gm.timestamp = self.timestamp
        gm.ts_last_move = self.ts_last_move
        gm.set_player(0, self.player_ids[0])
        gm.set_player(1, self.player_ids[1])
        gm.irack0 = self.initial_racks[0]
        gm.irack1 = self.initial_racks[1]
        gm.rack0 = self.state.rack(0)
        gm.rack1 = self.state.rack(1)
        gm.over = self.is_over()
        sc = self.final_scores() # Includes adjustments if game is over
        gm.score0 = sc[0]
        gm.score1 = sc[1]
        gm.to_move = len(self.moves) % 2
        gm.robot_level = self.robot_level
        gm.prefs = self._preferences
        tile_count = 0
        movelist = []
        best_word = [None, None]
        best_word_score = [0, 0]
        player = 0
        for m in self.moves:
            mm = MoveModel()
            coord, tiles, score = m.move.summary(self.state.board())
            if coord:
                # Regular move: count the tiles actually laid down
                tile_count += m.move.num_covers()
                # Keep track of best words laid down by each player
                if score > best_word_score[player]:
                    best_word_score[player] = score
                    best_word[player] = tiles
            mm.coord = coord
            mm.tiles = tiles
            mm.score = score
            mm.rack = m.rack
            mm.timestamp = m.ts
            movelist.append(mm)
            player = 1 - player
        gm.moves = movelist
        gm.tile_count = tile_count
        # Update the database entity
        gm.put()

        # Storing a game that is now over: update the player statistics as well
        if self.is_over():
            pid_0 = self.player_ids[0]
            pid_1 = self.player_ids[1]
            u0 = User.load(pid_0) if pid_0 else None
            u1 = User.load(pid_1) if pid_1 else None
            if u0:
                mod_0 = u0.adjust_highest_score(sc[0], self.uuid)
                mod_0 |= u0.adjust_best_word(best_word[0], best_word_score[0], self.uuid)
                if mod_0:
                    # Modified: store the updated user entity
                    u0.update()
            if u1:
                mod_1 = u1.adjust_highest_score(sc[1], self.uuid)
                mod_1 |= u1.adjust_best_word(best_word[1], best_word_score[1], self.uuid)
                if mod_1:
                    # Modified: store the updated user entity
                    u1.update()

    def id(self):
        """ Returns the unique id of this game """
        return self.uuid

    @staticmethod
    def autoplayer_name(level):
        """ Return the autoplayer name for a given level """
        i = len(Game.AUTOPLAYERS)
        while i > 0:
            i -= 1
            if level >= Game.AUTOPLAYERS[i][2]:
                return Game.AUTOPLAYERS[i][0]
        return Game.AUTOPLAYERS[0][0] # Strongest player by default

    def player_nickname(self, index):
        """ Returns the nickname of a player """
        u = None if self.player_ids[index] is None else User.load(self.player_ids[index])
        if u is None:
            # This is an autoplayer
            nick = Game.autoplayer_name(self.robot_level)
        else:
            # This is a human user
            nick = u.nickname()
            if nick[0:8] == u"https://":
                # Raw name (path) from Google Accounts: use a more readable version
                nick = Game.UNDEFINED_NAME
        return nick

    def get_pref(self, pref):
        """ Retrieve a preference, or None if not found """
        if self._preferences is None:
            return None
        return self._preferences.get(pref, None)

    def set_pref(self, pref, value):
        """ Set a preference to a value """
        if self._preferences is None:
            self._preferences = { }
        self._preferences[pref] = value

    def get_fairplay(self):
        """ True if this was originated as a fairplay game """
        return self.get_pref(u"fairplay") or False

    def set_fairplay(self, state):
        """ Set the fairplay commitment of this game """
        self.set_pref(u"fairplay", state)

    @staticmethod
    def get_duration_from_prefs(prefs):
        """ Return the duration given a dict of game preferences """
        return 0 if prefs is None else prefs.get(u"duration", 0)

    def get_duration(self):
        """ Return the duration for each player in the game, e.g. 25 if 2x25 minute game """
        return self.get_pref(u"duration") or 0

    def set_duration(self, duration):
        """ Set the duration for each player in the game, e.g. 25 if 2x25 minute game """
        self.set_pref(u"duration", duration)

    def is_overdue(self):
        """ Return True if no move has been made in the game for OVERDUE_DAYS days """
        ts_last_move = self.ts_last_move or self.timestamp
        delta = datetime.utcnow() - ts_last_move
        return delta >= timedelta(days = Game.OVERDUE_DAYS)

    def get_elapsed(self):
        """ Return the elapsed time for both players, in seconds, as a tuple """
        elapsed = [0.0, 0.0]
        last_ts = self.timestamp
        for m in self.moves:
            if m.ts is not None:
                delta = m.ts - last_ts
                last_ts = m.ts
                elapsed[m.player] += delta.total_seconds()
        if not self.state.is_game_over():
            # Game still going on: Add the time from the last move until now
            delta = datetime.utcnow() - last_ts
            elapsed[self.player_to_move()] += delta.total_seconds()
        return tuple(elapsed)

    def time_info(self):
        """ Returns a dict with timing information about this game """
        return dict(duration = self.get_duration(), elapsed = self.get_elapsed())

    def overtime(self):
        """ Return overtime for both players, in seconds """
        overtime = [0, 0]
        duration = self.get_duration() * 60.0 # In seconds
        if duration > 0.0:
            # Timed game: calculate the overtime
            el = self.get_elapsed()
            for player in range(2):
                overtime[player] = max(0.0, el[player] - duration) # Never negative
        return tuple(overtime)

    def overtime_adjustment(self):
        """ Return score adjustments due to overtime, as a tuple with two deltas """
        overtime = self.overtime()
        adjustment = [0, 0]
        for player in range(2):
            if overtime[player] > 0.0:
                # 10 point subtraction for every started minute
                # The formula means that 0.1 second into a new minute
                # a 10-point loss is incurred
                # After 10 minutes, the game is lost and the adjustment maxes out at -100
                adjustment[player] = max(-100, -10 * ((int(overtime[player] + 0.9) + 59) // 60))
        return tuple(adjustment)

    def is_over(self):
        """ Return True if the game is over """
        if self._game_over:
            # Use the cached result if available and True
            return True
        if self.state.is_game_over():
            self._game_over = True
            return True
        if self.get_duration() == 0:
            # Not a timed game: it's not over
            return False
        # Timed game: might now be lost on overtime
        overtime = self.overtime()
        if any(overtime[ix] >= Game.MAX_OVERTIME for ix in range(2)):
            # The game has been lost on overtime
            self._game_over = True
            return True
        return False

    def winning_player(self):
        """ Returns index of winning player, or -1 if game is tied or not over """
        if not self.is_over():
            return -1
        sc = self.final_scores()
        if sc[0] > sc[1]:
            return 0
        if sc[1] > sc[0]:
            return 1
        return -1

    def finalize_score(self):
        """ Adjust the score at the end of the game, accounting for left tiles, overtime, etc. """
        assert self.is_over()
        # Final adjustments to score, including rack leave and overtime, if any
        overtime = self.overtime()
        # Check whether a player lost on overtime
        lost_on_overtime = None
        for player in range(2):
            if overtime[player] >= Game.MAX_OVERTIME:
                lost_on_overtime = player
                break
        self.state.finalize_score(lost_on_overtime, self.overtime_adjustment())

    def final_scores(self):
        """ Return the final score of the game after adjustments, if any """
        return self.state.final_scores()

    def allows_best_moves(self):
        """ Returns True if this game supports full review (has stored racks, etc.) """
        if self.initial_racks[0] is None or self.initial_racks[1] is None:
            # This is an old game stored without rack information: can't display best moves
            return False
        # Never show best moves for games that are still being played
        return self.is_over()

    def register_move(self, move):
        """ Register a new move, updating the score and appending to the move list """
        player_index = self.player_to_move()
        self.state.apply_move(move)
        self.ts_last_move = datetime.utcnow()
        self.moves.append(MoveTuple(player_index, move,
            self.state.rack(player_index), self.ts_last_move))
        self.last_move = None # No response move yet

    def autoplayer_move(self):
        """ Generate an AutoPlayer move and register it """
        # Create an appropriate AutoPlayer subclass instance
        # depending on the robot level in question
        apl = AutoPlayer.create(self.state, self.robot_level)
        move = apl.generate_move()
        self.register_move(move)
        self.last_move = move # Store a response move

    def enum_tiles(self, state = None):
        """ Enumerate all tiles on the board in a convenient form """
        if state is None:
            state = self.state
        for x, y, tile, letter in state.board().enum_tiles():
            yield (Board.ROWIDS[x] + str(y + 1), tile, letter, Alphabet.scores[tile])

    def state_after_move(self, move_number):
        """ Return a game state after the indicated move, 0=beginning state """
        # Initialize a fresh state object
        s = State(drawtiles = False)
        # Set up the initial state
        for ix in range(2):
            s.set_player_name(ix, self.state.player_name(ix))
            if self.initial_racks[ix] is None:
                # Load the current rack rather than nothing
                s.set_rack(ix, self.state.rack(ix))
            else:
                # Load the initial rack
                s.set_rack(ix, self.initial_racks[ix])
        # Apply the moves up to the state point
        for m in self.moves[0 : move_number]:
            s.apply_move(m.move, shallow = True) # Shallow apply
            if m.rack is not None:
                s.set_rack(m.player, m.rack)
        s.recalc_bag()
        return s

    def display_bag(self, player_index):
        """ Returns the bag as it should be displayed to the indicated player,
            including the opponent's rack and sorted """
        return self.state.display_bag(player_index)

    def num_moves(self):
        """ Returns the number of moves in the game so far """
        return len(self.moves)

    def player_to_move(self):
        """ Returns the index (0 or 1) of the player whose move it is """
        return self.state.player_to_move()

    def player_id_to_move(self):
        """ Return the userid of the player whose turn it is, or None if autoplayer """
        return self.player_ids[self.player_to_move()]

    def player_id(self, player_index):
        """ Return the userid of the indicated player """
        return self.player_ids[player_index]

    def my_turn(self, user_id):
        """ Return True if it is the indicated player's turn to move """
        if self.is_over():
            return False
        return self.player_id_to_move() == user_id

    def is_autoplayer(self, player_index):
        """ Return True if the player in question is an autoplayer """
        return self.player_ids[player_index] is None

    def is_robot_game(self):
        """ Return True if one of the players is an autoplayer """
        return self.is_autoplayer(0) or self.is_autoplayer(1)

    def player_index(self, user_id):
        """ Return the player index (0 or 1) of the given user, or None if not a player """
        if self.player_ids[0] == user_id:
            return 0
        if self.player_ids[1] == user_id:
            return 1
        return None

    def has_player(self, user_id):
        """ Return True if the indicated user is a player of this game """
        return self.player_index(user_id) is not None

    def start_time(self):
        """ Returns the timestamp of the game in a readable format """
        return u"" if self.timestamp is None else Alphabet.format_timestamp(self.timestamp)

    def end_time(self):
        """ Returns the time of the last move in a readable format """
        return u"" if self.ts_last_move is None else Alphabet.format_timestamp(self.ts_last_move)

    def has_new_chat_msg(self, user_id):
        """ Return True if there is a new chat message that the given user hasn't seen """
        p = self.player_index(user_id)
        if p is None or self.is_autoplayer(1 - p):
            # The user is not a player of this game, or robot opponent: no chat
            return False
        # Check the database
        # !!! TBD: consider memcaching this
        return ChatModel.check_conversation(u"game:" + self.id(), user_id)

    def _append_final_adjustments(self, movelist):
        """ Appends final score adjustment transactions to the given movelist """

        # Lastplayer is the player who finished the game
        lastplayer = self.moves[-1].player if self.moves else 0

        if not self.state.is_resigned():

            # If the game did not end by resignation, check for a timeout
            overtime = self.overtime()
            adjustment = list(self.overtime_adjustment())
            sc = self.state.scores()

            if any(overtime[ix] >= Game.MAX_OVERTIME for ix in range(2)): # 10 minutes overtime
                # Game ended with a loss on overtime
                ix = 0 if overtime[0] >= Game.MAX_OVERTIME else 1
                adjustment[1 - ix] = 0
                # Adjust score of losing player down by 100 points
                adjustment[ix] = - min(100, sc[ix])
                # If losing player is still winning on points, add points to the
                # winning player so that she leads by one point
                if sc[ix] + adjustment[ix] >= sc[1 - ix]:
                    adjustment[1 - ix] = sc[ix] + adjustment[ix] + 1 - sc[1 - ix]
            else:
                # Normal end of game
                opp_rack = self.state.rack(1 - lastplayer)
                opp_score = Alphabet.score(opp_rack)
                last_rack = self.state.rack(lastplayer)
                last_score = Alphabet.score(last_rack)
                if not last_rack:
                    # Won with an empty rack: Add double the score of the losing rack
                    movelist.append((1 - lastplayer, (u"", u"--", 0)))
                    movelist.append((lastplayer, (u"", u"2 * " + opp_rack, 2 * opp_score)))
                else:
                    # The game has ended by passes: each player gets her own rack subtracted
                    movelist.append((1 - lastplayer, (u"", opp_rack, -1 * opp_score)))
                    movelist.append((lastplayer, (u"", last_rack, -1 * last_score)))

            # If this is a timed game, add eventual overtime adjustment
            if tuple(adjustment) != (0, 0):
                movelist.append((1 - lastplayer, (u"", u"TIME", adjustment[1 - lastplayer])))
                movelist.append((lastplayer, (u"", u"TIME", adjustment[lastplayer])))

        # Add a synthetic "game over" move
        movelist.append((1 - lastplayer, (u"", u"OVER", 0)))

    def get_final_adjustments(self):
        """ Get a fresh list of the final adjustments made to the game score """
        movelist = []
        self._append_final_adjustments(movelist)
        return movelist

    def client_state(self, player_index, lastmove = None):
        """ Create a package of information for the client about the current state """

        reply = dict()
        num_moves = 1
        if self.last_move is not None:
            # Show the autoplayer move that was made in response
            reply["lastmove"] = self.last_move.details()
            num_moves = 2 # One new move to be added to move list
        elif lastmove is not None:
            # The indicated move should be included in the client state
            # (used when notifying an opponent of a new move through a channel)
            reply["lastmove"] = lastmove.details()
        newmoves = [(m.player, m.move.summary(self.state.board())) for m in self.moves[-num_moves:]]

        if self.is_over():
            # The game is now over - one of the players finished it
            self._append_final_adjustments(newmoves)
            reply["result"] = Error.GAME_OVER # Not really an error
            reply["xchg"] = False # Exchange move not allowed
            reply["bag"] = self.state.bag().contents()
        else:
            # Game is still in progress
            reply["result"] = 0 # Indicate no error
            reply["xchg"] = self.state.is_exchange_allowed()
            reply["bag"] = self.display_bag(player_index)

        reply["rack"] = self.state.rack_details(player_index)
        reply["newmoves"] = newmoves
        reply["scores"] = self.final_scores()
        if self.get_duration():
            # Timed game: send information about elapsed time
            reply["time_info"] = self.time_info()
        return reply

    def bingoes(self):
        """ Returns a tuple of lists of bingoes for both players """
        board = self.state.board()
        # List all bingoes in the game
        bingoes = [(m.player, m.move.summary(board)) for m in self.moves if m.move.num_covers() == Rack.MAX_TILES]
        def _stripq(s):
            return s.replace(u'?', u'')
        # Populate (word, score) tuples for each bingo for each player
        bingo0 = [(_stripq(ms[1]), ms[2]) for p, ms in bingoes if p == 0]
        bingo1 = [(_stripq(ms[1]), ms[2]) for p, ms in bingoes if p == 1]
        return (bingo0, bingo1)

    def statistics(self):
        """ Return a set of statistics on the game to be displayed by the client """
        reply = dict()
        if self.is_over():
            reply["result"] = Error.GAME_OVER # Indicate that the game is over (not really an error)
        else:
            reply["result"] = 0 # Game still in progress
        reply["gamestart"] = self.start_time()
        reply["gameend"] = self.end_time()
        reply["duration"] = self.get_duration()
        reply["scores"] = sc = self.final_scores()
        # Number of moves made
        reply["moves0"] = m0 = (len(self.moves) + 1) // 2 # Floor division
        reply["moves1"] = m1 = (len(self.moves) + 0) // 2 # Floor division
        ncovers = [(m.player, m.move.num_covers()) for m in self.moves]
        bingoes = [(p, nc == Rack.MAX_TILES) for p, nc in ncovers]
        # Number of bingoes
        reply["bingoes0"] = sum([1 if p == 0 and bingo else 0 for p, bingo in bingoes])
        reply["bingoes1"] = sum([1 if p == 1 and bingo else 0 for p, bingo in bingoes])
        # Number of tiles laid down
        reply["tiles0"] = t0 = sum([nc if p == 0 else 0 for p, nc in ncovers])
        reply["tiles1"] = t1 = sum([nc if p == 1 else 0 for p, nc in ncovers])
        blanks = [0, 0]
        letterscore = [0, 0]
        cleanscore = [0, 0]
        # Loop through the moves, collecting stats
        for m in self.moves:
            coord, wrd, msc = m.move.summary(self.state.board())
            if wrd != u'RSGN':
                # Don't include a resignation penalty in the clean score
                cleanscore[m.player] += msc
            if m.move.num_covers() == 0:
                # Exchange, pass or resign move
                continue
            for coord, tile, letter, score in m.move.details():
                if tile == u'?':
                    blanks[m.player] += 1
                letterscore[m.player] += score
        # Number of blanks laid down
        reply["blanks0"] = b0 = blanks[0]
        reply["blanks1"] = b1 = blanks[1]
        # Sum of straight letter scores
        reply["letterscore0"] = lsc0 = letterscore[0]
        reply["letterscore1"] = lsc1 = letterscore[1]
        # Calculate average straight score of tiles laid down (excluding blanks)
        reply["average0"] = (float(lsc0) / (t0 - b0)) if (t0 > b0) else 0.0
        reply["average1"] = (float(lsc1) / (t1 - b1)) if (t1 > b1) else 0.0
        # Calculate point multiple of tiles laid down (score / nominal points)
        reply["multiple0"] = (float(cleanscore[0]) / lsc0) if (lsc0 > 0) else 0.0
        reply["multiple1"] = (float(cleanscore[1]) / lsc1) if (lsc1 > 0) else 0.0
        # Calculate average score of each move
        reply["avgmove0"] = (float(cleanscore[0]) / m0) if (m0 > 0) else 0.0
        reply["avgmove1"] = (float(cleanscore[1]) / m1) if (m1 > 0) else 0.0
        # Plain sum of move scores
        reply["cleantotal0"] = cleanscore[0]
        reply["cleantotal1"] = cleanscore[1]
        # Contribution of overtime at the end of the game
        ov = self.overtime()
        if any(ov[ix] >= Game.MAX_OVERTIME for ix in range(2)):
            # Game was lost on overtime
            reply["remaining0"] = 0
            reply["remaining1"] = 0
            reply["overtime0"] = sc[0] - cleanscore[0]
            reply["overtime1"] = sc[1] - cleanscore[1]
        else:
            oa = self.overtime_adjustment()
            reply["overtime0"] = oa[0]
            reply["overtime1"] = oa[1]
            # Contribution of remaining tiles at the end of the game
            reply["remaining0"] = sc[0] - cleanscore[0] - oa[0]
            reply["remaining1"] = sc[1] - cleanscore[1] - oa[1]
        # Score ratios (percentages)
        totalsc = sc[0] + sc[1]
        reply["ratio0"] = (float(sc[0]) / totalsc * 100.0) if totalsc > 0 else 0.0
        reply["ratio1"] = (float(sc[1]) / totalsc * 100.0) if totalsc > 0 else 0.0
        return reply
Exemplo n.º 10
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]
Exemplo n.º 11
0
def test_game(players, silent):
    """ Go through a whole game by pitting two AutoPlayers against each other """
    # The players parameter is a list of tuples: (playername, constructorfunc)
    # where constructorfunc accepts a State parameter and returns a freshly
    # created AutoPlayer (or subclass thereof) that will generate moves
    # on behalf of the player.

    # Initial, empty game state
    state = State(drawtiles=True)

    # Set player names
    for ix in range(2):
        state.set_player_name(ix, players[ix][0])

    if not silent:
        print(state.__str__())  # This works in Python 2 and 3

    # test_move(state, u"H4 stuði")
    # test_move(state, u"5E detts")
    # test_exchange(state, 3)
    # test_move(state, u"I3 dýs")
    # test_move(state, u"6E ?óx") # The question mark indicates a blank tile for the subsequent cover
    # state.player_rack().set_tiles(u"ðhknnmn")

    # Generate a sequence of moves, switching player sides automatically

    t0 = time.time()

    while not state.is_game_over():

        # Call the appropriate player creation function
        apl = players[state.player_to_move()][1](state)

        g0 = time.time()
        move = apl.generate_move()
        g1 = time.time()

        # legal = state.check_legality(move)
        # if legal != Error.LEGAL:
        #     # Oops: the autoplayer generated an illegal move
        #     print(u"Play is not legal, code {0}".format(Error.errortext(legal)))
        #     return
        if not silent:
            print(u"Play {0} scores {1} points ({2:.2f} seconds)".format(move, state.score(move), g1 - g0))

        # Apply the move to the state and switch players
        state.apply_move(move)

        if not silent:
            print(state.__str__())

    # Tally the tiles left and calculate the final score
    state.finalize_score()
    p0, p1 = state.scores()
    t1 = time.time()

    if not silent:
        print(
            u"Game over, final score {4} {0} : {5} {1} after {2} moves ({3:.2f} seconds)".format(
                p0, p1, state.num_moves(), t1 - t0, state.player_name(0), state.player_name(1)
            )
        )

    return state.scores()
Exemplo n.º 12
0
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
Exemplo n.º 13
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]
Exemplo n.º 14
0
 def state_after_move(self, move_number):
     """ Return a game state after the indicated move, 0=beginning state """
     # Initialize a fresh state object
     s = State(drawtiles=False, tileset=self.tileset)
     # Set up the initial state
     for ix in range(2):
         s.set_player_name(ix, self.state.player_name(ix))
         if self.initial_racks[ix] is None:
             # Load the current rack rather than nothing
             s.set_rack(ix, self.state.rack(ix))
         else:
             # Load the initial rack
             s.set_rack(ix, self.initial_racks[ix])
     # Apply the moves up to the state point
     for m in self.moves[0:move_number]:
         s.apply_move(m.move, shallow=True)  # Shallow apply
         if m.rack is not None:
             s.set_rack(m.player, m.rack)
     s.recalc_bag()
     return s
Exemplo n.º 15
0
    def _load_locked(cls, uuid, use_cache=True):
        """ Load an existing game from cache or persistent storage under lock """

        gm = GameModel.fetch(uuid, use_cache)
        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)

        # Set the timestamps
        game.timestamp = gm.timestamp
        game.ts_last_move = gm.ts_last_move
        if game.ts_last_move is None:
            # If no last move timestamp, default to the start of the game
            game.ts_last_move = game.timestamp

        # Initialize the preferences
        game._preferences = gm.prefs

        # Initialize a fresh, empty state with no tiles drawn into the racks
        game.state = State(drawtiles=False, tileset=game.tileset)

        # A player_id of None means that the player is an autoplayer (robot)
        game.player_ids[0] = None if gm.player0 is None else gm.player0.id()
        game.player_ids[1] = None if gm.player1 is None else gm.player1.id()

        game.robot_level = gm.robot_level

        # Load the initial racks
        game.initial_racks[0] = gm.irack0
        game.initial_racks[1] = gm.irack1

        # Load the current racks
        game.state.set_rack(0, gm.rack0)
        game.state.set_rack(1, gm.rack1)

        # Process the moves
        player = 0
        # mx = 0 # Move counter for debugging/logging
        for mm in gm.moves:

            # mx += 1
            # logging.info(u"Game move {0} tiles '{3}' score is {1}:{2}".format(mx, game.state._scores[0], game.state._scores[1], mm.tiles).encode("latin-1"))

            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
                if mm.tiles is not None:
                    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(MoveTuple(player, m, mm.rack, mm.timestamp))
                player = 1 - player

        # Find out what tiles are now in the bag
        game.state.recalc_bag()

        # Account for the final tiles in the rack and overtime, if any
        if game.is_over():
            game.finalize_score()
            if not gm.over:
                # The game was not marked as over when we loaded it from
                # the datastore, but it is over now. One of the players must
                # have lost on overtime. We need to update the persistent state.
                game._store_locked()

        return game
Exemplo n.º 16
0
class Game:
    """ A wrapper class for a particular game that is in process
        or completed. Contains inter alia a State instance.
    """

    # The available autoplayers (robots)
    AUTOPLAYERS = [
        (u"Fullsterkur", u"Velur stigahæsta leik í hverri stöðu", 0),
        (u"Miðlungur",
         u"Velur af handahófi einn af 8 stigahæstu leikjum í hverri stöðu", 8),
        (u"Amlóði",
         u"Forðast sjaldgæf orð og velur úr 20 leikjum sem koma til álita", 15)
    ]

    # The default nickname to display if a player has an unreadable nick
    # (for instance a default Google nick with a https:// prefix)
    UNDEFINED_NAME = u"[Ónefndur]"

    # The maximum overtime in a game, after which a player automatically loses
    MAX_OVERTIME = 10 * 60.0  # 10 minutes, in seconds

    # After this number of days the game becomes overdue and the
    # waiting player can force the tardy opponent to resign
    OVERDUE_DAYS = 14

    _lock = threading.Lock()

    def __init__(self, uuid=None):
        # Unique id of the game
        self.uuid = uuid
        # The start time of the game
        self.timestamp = None
        # The user ids of the players (None if autoplayer)
        # Player 0 is the one that begins the game
        self.player_ids = [None, None]
        # The current game state
        self.state = None
        # The ability level of the autoplayer (0 = strongest)
        self.robot_level = 0
        # The last move made by the remote player
        self.last_move = None
        # The timestamp of the last move made in the game
        self.ts_last_move = None
        # History of moves in this game so far, as a list of MoveTuple namedtuples
        self.moves = []
        # Initial rack contents
        self.initial_racks = [None, None]
        # Preferences (such as time limit, alternative bag or board, etc.)
        self._preferences = None
        # Cache of game over state (becomes True when the game is definitely over)
        self._game_over = None

    def _make_new(self, player0_id, player1_id, robot_level=0, prefs=None):
        """ Initialize a new, fresh game """
        self._preferences = prefs
        # If either player0_id or player1_id is None, this is a human-vs-autoplayer game
        self.player_ids = [player0_id, player1_id]
        self.state = State(drawtiles=True, tileset=self.tileset)
        self.initial_racks[0] = self.state.rack(0)
        self.initial_racks[1] = self.state.rack(1)
        self.robot_level = robot_level
        self.timestamp = self.ts_last_move = datetime.utcnow()

    @classmethod
    def new(cls, player0_id, player1_id, robot_level=0, prefs=None):
        """ Start and initialize a new game """
        game = cls(Unique.id())  # Assign a new unique id to the game
        if randint(0, 1) == 1:
            # Randomize which player starts the game
            player0_id, player1_id = player1_id, player0_id
        game._make_new(player0_id, player1_id, robot_level, prefs)
        # If AutoPlayer is first to move, generate the first move
        if game.player_id_to_move() is None:
            game.autoplayer_move()
        # Store the new game in persistent storage
        game.store()
        return game

    @classmethod
    def load(cls, uuid, use_cache=True):
        """ Load an already existing game from persistent storage """
        with Game._lock:
            # Ensure that the game load does not introduce race conditions
            return cls._load_locked(uuid, use_cache)

    def store(self):
        """ Store the game state in persistent storage """
        # Avoid race conditions by securing the lock before storing
        with Game._lock:
            self._store_locked()

    @classmethod
    def _load_locked(cls, uuid, use_cache=True):
        """ Load an existing game from cache or persistent storage under lock """

        gm = GameModel.fetch(uuid, use_cache)
        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)

        # Set the timestamps
        game.timestamp = gm.timestamp
        game.ts_last_move = gm.ts_last_move
        if game.ts_last_move is None:
            # If no last move timestamp, default to the start of the game
            game.ts_last_move = game.timestamp

        # Initialize the preferences
        game._preferences = gm.prefs

        # Initialize a fresh, empty state with no tiles drawn into the racks
        game.state = State(drawtiles=False, tileset=game.tileset)

        # A player_id of None means that the player is an autoplayer (robot)
        game.player_ids[0] = None if gm.player0 is None else gm.player0.id()
        game.player_ids[1] = None if gm.player1 is None else gm.player1.id()

        game.robot_level = gm.robot_level

        # Load the initial racks
        game.initial_racks[0] = gm.irack0
        game.initial_racks[1] = gm.irack1

        # Load the current racks
        game.state.set_rack(0, gm.rack0)
        game.state.set_rack(1, gm.rack1)

        # Process the moves
        player = 0
        # mx = 0 # Move counter for debugging/logging
        for mm in gm.moves:

            # mx += 1
            # logging.info(u"Game move {0} tiles '{3}' score is {1}:{2}".format(mx, game.state._scores[0], game.state._scores[1], mm.tiles).encode("latin-1"))

            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
                if mm.tiles is not None:
                    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(MoveTuple(player, m, mm.rack, mm.timestamp))
                player = 1 - player

        # Find out what tiles are now in the bag
        game.state.recalc_bag()

        # Account for the final tiles in the rack and overtime, if any
        if game.is_over():
            game.finalize_score()
            if not gm.over:
                # The game was not marked as over when we loaded it from
                # the datastore, but it is over now. One of the players must
                # have lost on overtime. We need to update the persistent state.
                game._store_locked()

        return game

    def _store_locked(self):
        """ Store the game after having acquired the object lock """

        assert self.uuid is not None

        gm = GameModel(id=self.uuid)
        gm.timestamp = self.timestamp
        gm.ts_last_move = self.ts_last_move
        gm.set_player(0, self.player_ids[0])
        gm.set_player(1, self.player_ids[1])
        gm.irack0 = self.initial_racks[0]
        gm.irack1 = self.initial_racks[1]
        gm.rack0 = self.state.rack(0)
        gm.rack1 = self.state.rack(1)
        gm.over = self.is_over()
        sc = self.final_scores()  # Includes adjustments if game is over
        gm.score0 = sc[0]
        gm.score1 = sc[1]
        gm.to_move = len(self.moves) % 2
        gm.robot_level = self.robot_level
        gm.prefs = self._preferences
        tile_count = 0
        movelist = []
        best_word = [None, None]
        best_word_score = [0, 0]
        player = 0
        for m in self.moves:
            mm = MoveModel()
            coord, tiles, score = m.move.summary(self.state)
            if coord:
                # Regular move: count the tiles actually laid down
                tile_count += m.move.num_covers()
                # Keep track of best words laid down by each player
                if score > best_word_score[player]:
                    best_word_score[player] = score
                    best_word[player] = tiles
            mm.coord = coord
            mm.tiles = tiles
            mm.score = score
            mm.rack = m.rack
            mm.timestamp = m.ts
            movelist.append(mm)
            player = 1 - player
        gm.moves = movelist
        gm.tile_count = tile_count
        # Update the database entity
        gm.put()

        # Storing a game that is now over: update the player statistics as well
        if self.is_over():
            pid_0 = self.player_ids[0]
            pid_1 = self.player_ids[1]
            u0 = User.load(pid_0) if pid_0 else None
            u1 = User.load(pid_1) if pid_1 else None
            if u0:
                mod_0 = u0.adjust_highest_score(sc[0], self.uuid)
                mod_0 |= u0.adjust_best_word(best_word[0], best_word_score[0],
                                             self.uuid)
                if mod_0:
                    # Modified: store the updated user entity
                    u0.update()
            if u1:
                mod_1 = u1.adjust_highest_score(sc[1], self.uuid)
                mod_1 |= u1.adjust_best_word(best_word[1], best_word_score[1],
                                             self.uuid)
                if mod_1:
                    # Modified: store the updated user entity
                    u1.update()

    def id(self):
        """ Returns the unique id of this game """
        return self.uuid

    @staticmethod
    def autoplayer_name(level):
        """ Return the autoplayer name for a given level """
        i = len(Game.AUTOPLAYERS)
        while i > 0:
            i -= 1
            if level >= Game.AUTOPLAYERS[i][2]:
                return Game.AUTOPLAYERS[i][0]
        return Game.AUTOPLAYERS[0][0]  # Strongest player by default

    def player_nickname(self, index):
        """ Returns the nickname of a player """
        u = None if self.player_ids[index] is None else User.load(
            self.player_ids[index])
        if u is None:
            # This is an autoplayer
            nick = Game.autoplayer_name(self.robot_level)
        else:
            # This is a human user
            nick = u.nickname()
            if nick[0:8] == u"https://":
                # Raw name (path) from Google Accounts: use a more readable version
                nick = Game.UNDEFINED_NAME
        return nick

    def get_pref(self, pref):
        """ Retrieve a preference, or None if not found """
        if self._preferences is None:
            return None
        return self._preferences.get(pref, None)

    def set_pref(self, pref, value):
        """ Set a preference to a value """
        if self._preferences is None:
            self._preferences = {}
        self._preferences[pref] = value

    @staticmethod
    def fairplay_from_prefs(prefs):
        """ Returns the fairplay commitment specified by the given game preferences """
        return prefs is not None and prefs.get(u"fairplay", False)

    def get_fairplay(self):
        """ True if this was originated as a fairplay game """
        return self.get_pref(u"fairplay") or False

    def set_fairplay(self, state):
        """ Set the fairplay commitment of this game """
        self.set_pref(u"fairplay", state)

    def new_bag(self):
        """ True if this game uses the new bag """
        return self.get_pref(u"newbag") or False

    def set_new_bag(self, state):
        """ Configures the game as using the new bag """
        self.set_pref(u"newbag", state)

    @staticmethod
    def new_bag_from_prefs(prefs):
        """ Returns true if the game preferences specify a new bag """
        return prefs is not None and prefs.get(u"newbag", False)

    @staticmethod
    def tileset_from_prefs(prefs):
        """ Returns the tileset specified by the given game preferences """
        new_bag = Game.new_bag_from_prefs(prefs)
        return NewTileSet if new_bag else OldTileSet

    @property
    def tileset(self):
        """ Return the tile set used in this game """
        return NewTileSet if self.new_bag() else OldTileSet

    @staticmethod
    def get_duration_from_prefs(prefs):
        """ Return the duration given a dict of game preferences """
        return 0 if prefs is None else prefs.get(u"duration", 0)

    def get_duration(self):
        """ Return the duration for each player in the game, e.g. 25 if 2x25 minute game """
        return self.get_pref(u"duration") or 0

    def set_duration(self, duration):
        """ Set the duration for each player in the game, e.g. 25 if 2x25 minute game """
        self.set_pref(u"duration", duration)

    def is_overdue(self):
        """ Return True if no move has been made in the game for OVERDUE_DAYS days """
        ts_last_move = self.ts_last_move or self.timestamp
        delta = datetime.utcnow() - ts_last_move
        return delta >= timedelta(days=Game.OVERDUE_DAYS)

    def get_elapsed(self):
        """ Return the elapsed time for both players, in seconds, as a tuple """
        elapsed = [0.0, 0.0]
        last_ts = self.timestamp
        for m in self.moves:
            if m.ts is not None:
                delta = m.ts - last_ts
                last_ts = m.ts
                elapsed[m.player] += delta.total_seconds()
        if not self.state.is_game_over():
            # Game still going on: Add the time from the last move until now
            delta = datetime.utcnow() - last_ts
            elapsed[self.player_to_move()] += delta.total_seconds()
        return tuple(elapsed)

    def time_info(self):
        """ Returns a dict with timing information about this game """
        return dict(duration=self.get_duration(), elapsed=self.get_elapsed())

    def overtime(self):
        """ Return overtime for both players, in seconds """
        overtime = [0, 0]
        duration = self.get_duration() * 60.0  # In seconds
        if duration > 0.0:
            # Timed game: calculate the overtime
            el = self.get_elapsed()
            for player in range(2):
                overtime[player] = max(0.0,
                                       el[player] - duration)  # Never negative
        return tuple(overtime)

    def overtime_adjustment(self):
        """ Return score adjustments due to overtime, as a tuple with two deltas """
        overtime = self.overtime()
        adjustment = [0, 0]
        for player in range(2):
            if overtime[player] > 0.0:
                # 10 point subtraction for every started minute
                # The formula means that 0.1 second into a new minute
                # a 10-point loss is incurred
                # After 10 minutes, the game is lost and the adjustment maxes out at -100
                adjustment[player] = max(
                    -100, -10 * ((int(overtime[player] + 0.9) + 59) // 60))
        return tuple(adjustment)

    def is_over(self):
        """ Return True if the game is over """
        if self._game_over:
            # Use the cached result if available and True
            return True
        if self.state.is_game_over():
            self._game_over = True
            return True
        if self.get_duration() == 0:
            # Not a timed game: it's not over
            return False
        # Timed game: might now be lost on overtime
        overtime = self.overtime()
        if any(overtime[ix] >= Game.MAX_OVERTIME for ix in range(2)):
            # The game has been lost on overtime
            self._game_over = True
            return True
        return False

    def winning_player(self):
        """ Returns index of winning player, or -1 if game is tied or not over """
        if not self.is_over():
            return -1
        sc = self.final_scores()
        if sc[0] > sc[1]:
            return 0
        if sc[1] > sc[0]:
            return 1
        return -1

    def finalize_score(self):
        """ Adjust the score at the end of the game, accounting for left tiles, overtime, etc. """
        assert self.is_over()
        # Final adjustments to score, including rack leave and overtime, if any
        overtime = self.overtime()
        # Check whether a player lost on overtime
        lost_on_overtime = None
        for player in range(2):
            if overtime[player] >= Game.MAX_OVERTIME:
                lost_on_overtime = player
                break
        self.state.finalize_score(lost_on_overtime, self.overtime_adjustment())

    def final_scores(self):
        """ Return the final score of the game after adjustments, if any """
        return self.state.final_scores()

    def allows_best_moves(self):
        """ Returns True if this game supports full review (has stored racks, etc.) """
        if self.initial_racks[0] is None or self.initial_racks[1] is None:
            # This is an old game stored without rack information: can't display best moves
            return False
        # Never show best moves for games that are still being played
        return self.is_over()

    def register_move(self, move):
        """ Register a new move, updating the score and appending to the move list """
        player_index = self.player_to_move()
        self.state.apply_move(move)
        self.ts_last_move = datetime.utcnow()
        self.moves.append(
            MoveTuple(player_index, move, self.state.rack(player_index),
                      self.ts_last_move))
        self.last_move = None  # No response move yet

    def autoplayer_move(self):
        """ Generate an AutoPlayer move and register it """
        # Create an appropriate AutoPlayer subclass instance
        # depending on the robot level in question
        apl = AutoPlayer.create(self.state, self.robot_level)
        move = apl.generate_move()
        self.register_move(move)
        self.last_move = move  # Store a response move

    def enum_tiles(self, state=None):
        """ Enumerate all tiles on the board in a convenient form """
        if state is None:
            state = self.state
        for x, y, tile, letter in state.board().enum_tiles():
            yield (Board.ROWIDS[x] + str(y + 1), tile, letter,
                   self.tileset.scores[tile])

    def state_after_move(self, move_number):
        """ Return a game state after the indicated move, 0=beginning state """
        # Initialize a fresh state object
        s = State(drawtiles=False, tileset=self.tileset)
        # Set up the initial state
        for ix in range(2):
            s.set_player_name(ix, self.state.player_name(ix))
            if self.initial_racks[ix] is None:
                # Load the current rack rather than nothing
                s.set_rack(ix, self.state.rack(ix))
            else:
                # Load the initial rack
                s.set_rack(ix, self.initial_racks[ix])
        # Apply the moves up to the state point
        for m in self.moves[0:move_number]:
            s.apply_move(m.move, shallow=True)  # Shallow apply
            if m.rack is not None:
                s.set_rack(m.player, m.rack)
        s.recalc_bag()
        return s

    def display_bag(self, player_index):
        """ Returns the bag as it should be displayed to the indicated player,
            including the opponent's rack and sorted """
        return self.state.display_bag(player_index)

    def num_moves(self):
        """ Returns the number of moves in the game so far """
        return len(self.moves)

    def player_to_move(self):
        """ Returns the index (0 or 1) of the player whose move it is """
        return self.state.player_to_move()

    def player_id_to_move(self):
        """ Return the userid of the player whose turn it is, or None if autoplayer """
        return self.player_ids[self.player_to_move()]

    def player_id(self, player_index):
        """ Return the userid of the indicated player """
        return self.player_ids[player_index]

    def my_turn(self, user_id):
        """ Return True if it is the indicated player's turn to move """
        if self.is_over():
            return False
        return self.player_id_to_move() == user_id

    def is_autoplayer(self, player_index):
        """ Return True if the player in question is an autoplayer """
        return self.player_ids[player_index] is None

    def is_robot_game(self):
        """ Return True if one of the players is an autoplayer """
        return self.is_autoplayer(0) or self.is_autoplayer(1)

    def player_index(self, user_id):
        """ Return the player index (0 or 1) of the given user, or None if not a player """
        if self.player_ids[0] == user_id:
            return 0
        if self.player_ids[1] == user_id:
            return 1
        return None

    def has_player(self, user_id):
        """ Return True if the indicated user is a player of this game """
        return self.player_index(user_id) is not None

    def start_time(self):
        """ Returns the timestamp of the game in a readable format """
        return u"" if self.timestamp is None else Alphabet.format_timestamp(
            self.timestamp)

    def end_time(self):
        """ Returns the time of the last move in a readable format """
        return u"" if self.ts_last_move is None else Alphabet.format_timestamp(
            self.ts_last_move)

    def has_new_chat_msg(self, user_id):
        """ Return True if there is a new chat message that the given user hasn't seen """
        p = self.player_index(user_id)
        if p is None or self.is_autoplayer(1 - p):
            # The user is not a player of this game, or robot opponent: no chat
            return False
        # Check the database
        # !!! TBD: consider memcaching this
        return ChatModel.check_conversation(u"game:" + self.id(), user_id)

    def _append_final_adjustments(self, movelist):
        """ Appends final score adjustment transactions to the given movelist """

        # Lastplayer is the player who finished the game
        lastplayer = self.moves[-1].player if self.moves else 0

        if not self.state.is_resigned():

            # If the game did not end by resignation, check for a timeout
            overtime = self.overtime()
            adjustment = list(self.overtime_adjustment())
            sc = self.state.scores()

            if any(overtime[ix] >= Game.MAX_OVERTIME
                   for ix in range(2)):  # 10 minutes overtime
                # Game ended with a loss on overtime
                ix = 0 if overtime[0] >= Game.MAX_OVERTIME else 1
                adjustment[1 - ix] = 0
                # Adjust score of losing player down by 100 points
                adjustment[ix] = -min(100, sc[ix])
                # If losing player is still winning on points, add points to the
                # winning player so that she leads by one point
                if sc[ix] + adjustment[ix] >= sc[1 - ix]:
                    adjustment[1 -
                               ix] = sc[ix] + adjustment[ix] + 1 - sc[1 - ix]
            else:
                # Normal end of game
                opp_rack = self.state.rack(1 - lastplayer)
                opp_score = self.tileset.score(opp_rack)
                last_rack = self.state.rack(lastplayer)
                last_score = self.tileset.score(last_rack)
                if not last_rack:
                    # Won with an empty rack: Add double the score of the losing rack
                    movelist.append((1 - lastplayer, (u"", u"--", 0)))
                    movelist.append(
                        (lastplayer, (u"", u"2 * " + opp_rack, 2 * opp_score)))
                else:
                    # The game has ended by passes: each player gets her own rack subtracted
                    movelist.append(
                        (1 - lastplayer, (u"", opp_rack, -1 * opp_score)))
                    movelist.append(
                        (lastplayer, (u"", last_rack, -1 * last_score)))

            # If this is a timed game, add eventual overtime adjustment
            if tuple(adjustment) != (0, 0):
                movelist.append((1 - lastplayer, (u"", u"TIME",
                                                  adjustment[1 - lastplayer])))
                movelist.append(
                    (lastplayer, (u"", u"TIME", adjustment[lastplayer])))

        # Add a synthetic "game over" move
        movelist.append((1 - lastplayer, (u"", u"OVER", 0)))

    def get_final_adjustments(self):
        """ Get a fresh list of the final adjustments made to the game score """
        movelist = []
        self._append_final_adjustments(movelist)
        return movelist

    def client_state(self, player_index, lastmove=None):
        """ Create a package of information for the client about the current state """

        reply = dict()
        num_moves = 1
        if self.last_move is not None:
            # Show the autoplayer move that was made in response
            reply["lastmove"] = self.last_move.details(self.state)
            num_moves = 2  # One new move to be added to move list
        elif lastmove is not None:
            # The indicated move should be included in the client state
            # (used when notifying an opponent of a new move through a channel)
            reply["lastmove"] = lastmove.details(self.state)
        newmoves = [(m.player, m.move.summary(self.state))
                    for m in self.moves[-num_moves:]]

        if self.is_over():
            # The game is now over - one of the players finished it
            self._append_final_adjustments(newmoves)
            reply["result"] = Error.GAME_OVER  # Not really an error
            reply["xchg"] = False  # Exchange move not allowed
            reply["bag"] = self.state.bag().contents()
        else:
            # Game is still in progress
            reply["result"] = 0  # Indicate no error
            reply["xchg"] = self.state.is_exchange_allowed()
            reply["bag"] = self.display_bag(player_index)

        reply["rack"] = self.state.rack_details(player_index)
        reply["newmoves"] = newmoves
        reply["scores"] = self.final_scores()
        if self.get_duration():
            # Timed game: send information about elapsed time
            reply["time_info"] = self.time_info()
        return reply

    def bingoes(self):
        """ Returns a tuple of lists of bingoes for both players """
        # List all bingoes in the game
        bingoes = [(m.player, m.move.summary(self.state)) for m in self.moves
                   if m.move.num_covers() == Rack.MAX_TILES]

        def _stripq(s):
            return s.replace(u'?', u'')

        # Populate (word, score) tuples for each bingo for each player
        bingo0 = [(_stripq(ms[1]), ms[2]) for p, ms in bingoes if p == 0]
        bingo1 = [(_stripq(ms[1]), ms[2]) for p, ms in bingoes if p == 1]
        return (bingo0, bingo1)

    def statistics(self):
        """ Return a set of statistics on the game to be displayed by the client """
        reply = dict()
        if self.is_over():
            reply[
                "result"] = Error.GAME_OVER  # Indicate that the game is over (not really an error)
        else:
            reply["result"] = 0  # Game still in progress
        reply["gamestart"] = self.start_time()
        reply["gameend"] = self.end_time()
        reply["duration"] = self.get_duration()
        reply["scores"] = sc = self.final_scores()
        # New bag?
        reply["newbag"] = self.new_bag()
        # Number of moves made
        reply["moves0"] = m0 = (len(self.moves) + 1) // 2  # Floor division
        reply["moves1"] = m1 = (len(self.moves) + 0) // 2  # Floor division
        ncovers = [(m.player, m.move.num_covers()) for m in self.moves]
        bingoes = [(p, nc == Rack.MAX_TILES) for p, nc in ncovers]
        # Number of bingoes
        reply["bingoes0"] = sum(
            [1 if p == 0 and bingo else 0 for p, bingo in bingoes])
        reply["bingoes1"] = sum(
            [1 if p == 1 and bingo else 0 for p, bingo in bingoes])
        # Number of tiles laid down
        reply["tiles0"] = t0 = sum([nc if p == 0 else 0 for p, nc in ncovers])
        reply["tiles1"] = t1 = sum([nc if p == 1 else 0 for p, nc in ncovers])
        blanks = [0, 0]
        letterscore = [0, 0]
        cleanscore = [0, 0]
        # Loop through the moves, collecting stats
        for m in self.moves:
            coord, wrd, msc = m.move.summary(self.state)
            if wrd != u'RSGN':
                # Don't include a resignation penalty in the clean score
                cleanscore[m.player] += msc
            if m.move.num_covers() == 0:
                # Exchange, pass or resign move
                continue
            for coord, tile, letter, score in m.move.details(self.state):
                if tile == u'?':
                    blanks[m.player] += 1
                letterscore[m.player] += score
        # Number of blanks laid down
        reply["blanks0"] = b0 = blanks[0]
        reply["blanks1"] = b1 = blanks[1]
        # Sum of straight letter scores
        reply["letterscore0"] = lsc0 = letterscore[0]
        reply["letterscore1"] = lsc1 = letterscore[1]
        # Calculate average straight score of tiles laid down (excluding blanks)
        reply["average0"] = (float(lsc0) / (t0 - b0)) if (t0 > b0) else 0.0
        reply["average1"] = (float(lsc1) / (t1 - b1)) if (t1 > b1) else 0.0
        # Calculate point multiple of tiles laid down (score / nominal points)
        reply["multiple0"] = (float(cleanscore[0]) /
                              lsc0) if (lsc0 > 0) else 0.0
        reply["multiple1"] = (float(cleanscore[1]) /
                              lsc1) if (lsc1 > 0) else 0.0
        # Calculate average score of each move
        reply["avgmove0"] = (float(cleanscore[0]) / m0) if (m0 > 0) else 0.0
        reply["avgmove1"] = (float(cleanscore[1]) / m1) if (m1 > 0) else 0.0
        # Plain sum of move scores
        reply["cleantotal0"] = cleanscore[0]
        reply["cleantotal1"] = cleanscore[1]
        # Contribution of overtime at the end of the game
        ov = self.overtime()
        if any(ov[ix] >= Game.MAX_OVERTIME for ix in range(2)):
            # Game was lost on overtime
            reply["remaining0"] = 0
            reply["remaining1"] = 0
            reply["overtime0"] = sc[0] - cleanscore[0]
            reply["overtime1"] = sc[1] - cleanscore[1]
        else:
            oa = self.overtime_adjustment()
            reply["overtime0"] = oa[0]
            reply["overtime1"] = oa[1]
            # Contribution of remaining tiles at the end of the game
            reply["remaining0"] = sc[0] - cleanscore[0] - oa[0]
            reply["remaining1"] = sc[1] - cleanscore[1] - oa[1]
        # Score ratios (percentages)
        totalsc = sc[0] + sc[1]
        reply["ratio0"] = (float(sc[0]) / totalsc *
                           100.0) if totalsc > 0 else 0.0
        reply["ratio1"] = (float(sc[1]) / totalsc *
                           100.0) if totalsc > 0 else 0.0
        return reply