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()
def test_manual_game(): """ Manual game test """ # Initial, empty game state state = State(tileset=NewTileSet, manual_wordcheck=True, drawtiles=True) print("Manual game") print("After initial draw, bag contains {0} tiles".format( state.bag().num_tiles())) print("Bag contents are:\n{0}".format(state.bag().contents())) print("Rack 0 is {0}".format(state.rack(0))) print("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("stuðinn") test_move(state, "H4 stuði") state.player_rack().set_tiles("dettsfj") test_move(state, "5E detts") test_exchange(state, 3) state.player_rack().set_tiles("dýsturi") test_move(state, "I3 dýs") state.player_rack().set_tiles("?xalmen") # The question mark indicates a blank tile for the subsequent cover test_move(state, "6E ?óx") state.player_rack().set_tiles("eiðarps") test_move(state, "9F eipar") test_challenge(state) test_response(state) state.player_rack().set_tiles("sóbetis") test_move(state, "J3 ós") test_move(state, "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( "Manual game over, final score {3} {0} : {4} {1} after {2} moves" .format( p0, p1, state.num_moves(), state.player_name(0), state.player_name(1) ) )
def test_manual_game(): """ Manual game test """ # Initial, empty game state state = State(tileset = NewTileSet, manual_wordcheck = True, drawtiles = True) print(u"Manual game") print(u"After initial draw, bag contains {0} tiles".format(state.bag().num_tiles())) print(u"Bag contents are:\n{0}".format(state.bag().contents())) print(u"Rack 0 is {0}".format(state.rack(0))) print(u"Rack 1 is {0}".format(state.rack(1))) # Set player names for ix in range(2): state.set_player_name(ix, "Player " + ("A", "B")[ix]) # print(state.__str__()) # This works in Python 2 and 3 state.player_rack().set_tiles(u"stuðinn") test_move(state, u"H4 stuði") state.player_rack().set_tiles(u"dettsfj") test_move(state, u"5E detts") test_exchange(state, 3) state.player_rack().set_tiles(u"dýsturi") test_move(state, u"I3 dýs") state.player_rack().set_tiles(u"?xalmen") test_move(state, u"6E ?óx") # The question mark indicates a blank tile for the subsequent cover state.player_rack().set_tiles(u"eiðarps") test_move(state, u"9F eipar") test_challenge(state) test_response(state) state.player_rack().set_tiles(u"sóbetis") test_move(state, u"J3 ós") test_move(state, u"9F eiðar") test_challenge(state) test_response(state) # Tally the tiles left and calculate the final score state.finalize_score() p0, p1 = state.scores() print(u"Manual game over, final score {3} {0} : {4} {1} after {2} moves".format(p0, p1, state.num_moves(), state.player_name(0), state.player_name(1)))
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()
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
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()
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