def _get_voted_out_player(self, team: Team, gamedb: Database) -> [Player, None]: high = 0 candidates = [] log_message(message="Counting votes from team {}.".format(team), game_id=self._game_id) team_votes = gamedb.count_votes(from_team=team) log_message(message="Got votes {}.".format(pprint.pformat(team_votes)), game_id=self._game_id) for _, votes in team_votes.items(): if votes > high: high = votes for id, votes in team_votes.items(): if votes == high: candidates.append(id) num_candidates = len(candidates) if num_candidates == 1: return gamedb.player_from_id(candidates[0]) elif num_candidates > 1: return gamedb.player_from_id(candidates[random.randint( 0, num_candidates - 1)]) else: raise GameError("Unable to determine voted out player.")
def _score_entries_tribe_aggregate(self, tribe: Tribe, challenge: Challenge, gamedb: Database, engine: Engine): score_dict = {'score': 0} players = gamedb.count_players(from_tribe=tribe) entries = gamedb.stream_entries(from_tribe=tribe, from_challenge=challenge) with ThreadPoolExecutor(max_workers=self._options. engine_worker_thread_count) as executor: executor.submit(self._score_entries_tribe_aggregate_fn, entries=entries, challenge=challenge, score_dict=score_dict, gamedb=gamedb, engine=engine) # tribe score = avg score of all tribe members log_message(message="_score_entries_tribe_agg = {}.".format( score_dict['score']), game_id=self._game_id) if players > 0: return score_dict['score'] / players return 0
def _run_single_team_council(self, team: Team, losing_players: List[Player], gamedb: Database, engine: Engine): # announce winner and tribal council for losing teams gamedb.clear_votes() winning_player = [player for player in gamedb.list_players( from_team=team) if player not in losing_players][0] engine.add_event(events.NotifySingleTeamCouncilEvent(game_id=self._game_id, game_options=self._options, winning_player=winning_player, losing_players=losing_players)) tribal_council_start_timestamp = _unixtime() # wait for votes while (((_unixtime() - tribal_council_start_timestamp) < self._options.single_team_council_time_sec) and not self._stop.is_set()): log_message("Waiting for tribal council to end.") time.sleep(self._options.game_wait_sleep_interval_sec) # count votes voted_out_player = self._get_voted_out_player(team=team, gamedb=gamedb) if voted_out_player: gamedb.deactivate_player(player=voted_out_player) log_message("Deactivated player {}.".format(voted_out_player)) engine.add_event(events.NotifyPlayerVotedOutEvent(game_id=self._game_id, game_options=self._options, player=voted_out_player)) # notify all players of what happened at tribal council engine.add_event( events.NotifyTribalCouncilCompletionEvent(game_id=self._game_id, game_options=self._options))
def _score_entries_top_k_teams_fn(self, entries: Iterable, challenge: Challenge, score_dict: Dict, gamedb: Database, engine: Engine): entries_iter = iter(entries) while not self._stop_event.is_set(): try: entry = next(entries_iter) log_message(message="Entry {}.".format(entry), game_id=self._game_id) points = self._score_entry(entry=entry) player = gamedb.player_from_id(entry.player_id) engine.add_event( events.NotifyPlayerScoreEvent(game_id=self._game_id, game_options=self._options, player=player, challenge=challenge, entry=entry, points=points)) if player.team_id not in score_dict: score_dict[player.team_id] = points else: score_dict[player.team_id] += points except StopIteration: break
def set_stop(self): log_message("Received stop signal for matchmaker in region={}".format( self._region)) self._stop.set() # Wait for thread to finish executing/sleeping. This may take a long time self._thread.join() self._daemon_started = False
def _score_entries_top_k_players(self, team: Team, challenge: Challenge, gamedb: Database, engine: Engine) -> List[Player]: player_scores = {} top_scores = list() losing_players = list() entries = gamedb.stream_entries( from_team=team, from_challenge=challenge) with ThreadPoolExecutor(max_workers=self._options.engine_worker_thread_count) as executor: executor.submit(self._score_entries_top_k_players_fn, entries=entries, challenge=challenge, score_dict=player_scores, gamedb=gamedb, engine=engine) for player_id, score in player_scores.items(): heapq.heappush(top_scores, (score, player_id)) # note that the default python heap pops in ascending order, # so the rank here is actually worst to best. num_scores = len(top_scores) if num_scores == 1: raise GameError( "Unable to rank losing players with team size = 1.") else: for rank in range(num_scores): score, player_id = heapq.heappop(top_scores) log_message("Player {} rank {} with score {}.".format( player_id, rank, score)) # all but the highest scorer lose if rank < (num_scores - 1): losing_players.append(gamedb.player_from_id(player_id)) return losing_players
def _run_finalist_tribe_council(self, finalists: List[Player], gamedb: Database, engine: Engine) -> Player: gamedb.clear_votes() engine.add_event( events.NotifyFinalTribalCouncilEvent( game_id=self._game_id, game_options=self._options, finalists=finalists)) tribal_council_start_timestamp = _unixtime() # wait for votes while (((_unixtime() - tribal_council_start_timestamp) < self._options.final_tribal_council_time_sec) and not self._stop.is_set()): log_message("Waiting for tribal council to end.") time.sleep(self._options.game_wait_sleep_interval_sec) # count votes player_votes = gamedb.count_votes(is_for_win=True) max_votes = 0 winner = None for player_id, votes in player_votes.items(): if votes > max_votes: max_votes = votes winner = gamedb.player_from_id(id=player_id) # announce winner engine.add_event(events.NotifyWinnerAnnouncementEvent( game_id=self._game_id, game_options=self._options, winner=winner)) return winner
def _play_game(self, game: Game, game_snap: DocumentSnapshot, players: list, game_dict: dict, is_test: bool = False): log_message("Starting a game", game_id=game_dict.get("id"), additional_tags=game_dict) if is_test: database = MockDatabase() engine = MockPlayEngine().CreateEngine(database) else: # NOTE(brandon): the game DB instance used by the matchmaker is for searching over all games. when we create # a game instance, we also supply new game DB and engine objects that have the specific game ID. database = FirestoreDB(json_config_path=json_config_path, game_id=game._game_id) engine = Engine(options=game._options, game_id=game._game_id, sqs_config_path=_TEST_AMAZON_SQS_CONFIG_PATH, twilio_config_path=_TEST_TWILIO_SMS_CONFIG_PATH, gamedb=database) try: game_data = self._matchmaker.generate_tribes( game_id=game._game_id, players=players, game_options=game._options, gamedb=database) tribes = game_data['tribes'] message = messages.NOTIFY_GAME_STARTED_EVENT_MSG_FMT.format( header=messages.game_sms_header( hashtag=game_dict.get('hashtag')), game=game_dict.get('hashtag')) self._notify_players(game_id=game._game_id, players=players, message=message) if self._is_mvp: # NOTE(brandon): changing to thread for now. can't pickle non-primitive engine object. game_thread = threading.Thread(target=game.play, args=(tribes[0], tribes[1], database, engine)) game_thread.start() else: # start on new GCP instance pass except MatchMakerError as e: # Catches error from matchmaker algorithm message = "Matchmaker Error: {}".format(e) log_message(message=message, game_id=game._game_id) self._set_game_has_started(game_snap=game_snap, game=game, value=False) self._notify_players(game_id=game._game_id, players=players, message=message) self._reschedule_or_cancel_game(game_snap=game_snap, game_dict=game_dict, players=players)
def _get_challenge(self, gamedb: Database) -> Challenge: available_challenge_count = 0 while available_challenge_count == 0 and not self._stop.is_set(): log_message("Waiting for next challenge to become available.") time.sleep(self._options.game_wait_sleep_interval_sec) available_challenges = gamedb.list_challenges( challenge_completed_predicate_value=False) available_challenge_count = len(available_challenges) return available_challenges[0]
def _score_entries_top_k_teams( self, k: float, tribe: Tribe, challenge: Challenge, gamedb: Database, engine: Engine) -> Tuple[List[Team], List[Team]]: team_scores = {} top_scores = list() winning_teams = list() losing_teams = list() entries = gamedb.stream_entries(from_tribe=tribe, from_challenge=challenge) with ThreadPoolExecutor(max_workers=self._options. engine_worker_thread_count) as executor: executor.submit(self._score_entries_top_k_teams_fn, entries=entries, challenge=challenge, score_dict=team_scores, gamedb=gamedb, engine=engine) for team_id, score in team_scores.items(): heapq.heappush( top_scores, (score / gamedb.count_players(from_team=gamedb.team_from_id(team_id)), team_id)) rank_threshold = float(k * len(top_scores)) log_message(message="Rank threshold = {}".format(rank_threshold), game_id=self._game_id) # note that the default python heap pops in ascending order, # so the rank here is actually worst to best. num_scores = len(top_scores) if num_scores == 1: score, team_id = heapq.heappop(top_scores) log_message(message="Winner {}.".format(team_id), game_id=self._game_id) winning_teams = [gamedb.team_from_id(team_id)] else: for rank in range(num_scores): score, team_id = heapq.heappop(top_scores) log_message(message="Team {} rank {} with score {}.".format( team_id, rank, score), game_id=self._game_id) if rank >= rank_threshold: log_message(message="Winner {}.".format(team_id), game_id=self._game_id) winning_teams.append(gamedb.team_from_id(team_id)) else: log_message(message="Loser {}.".format(team_id), game_id=self._game_id) losing_teams.append(gamedb.team_from_id(team_id)) return (winning_teams, losing_teams)
def put_fn(self, event: SMSEvent) -> None: # TODO(brandon) add retry logic and error handling. log_message("Putting {} on queue {}.".format( event.to_json(), self._url)) response = self._client.send_message( QueueUrl=self._url, MessageBody=event.to_json(), MessageGroupId=event.game_id, MessageDeduplicationId=str(uuid.uuid4()) ) log_message(response)
def start_matchmaker_daemon(self, sleep_seconds: int = 60, is_test: bool = False): if not self._daemon_started and not self._stop.is_set(): self._thread = threading.Thread(target=self._matchmaker_function, args=(sleep_seconds, is_test)) self._daemon_started = True self._thread.start() else: log_message( "Failed to start new matchmaker for region={} (matchmaker already running)" .format(self._region))
def _merge_teams(self, target_team_size: int, tribe: Tribe, gamedb: Database, engine: Engine): # team merging is only necessary when the size of the team == 2 # once a team size == 2, it should be merged with another team. the optimal # choice is to keep team sizes as close to the intended size as possible # find all teams with size = 2, these players need to be merged small_teams = gamedb.stream_teams( from_tribe=tribe, team_size_predicate_value=2) merge_candidates = Queue() for team in small_teams: log_message("Found team of 2. Deacticating team {}.".format(team)) # do not deactivate the last active team in the tribe if gamedb.count_teams(from_tribe=tribe, active_team_predicate_value=True) > 1: gamedb.deactivate_team(team) for player in gamedb.list_players(from_team=team): log_message("Adding merge candidate {}.".format(player)) merge_candidates.put(player) sorted_teams = gamedb.stream_teams( from_tribe=tribe, order_by_size=True, descending=False) log_message("Redistributing merge candidates...") # round robin redistribution strategy # simplest case, could use more thought. visited = {} while not merge_candidates.empty() and sorted_teams: for team in sorted_teams: other_options_available = team.id not in visited visited[team.id] = True if (team.size >= target_team_size and other_options_available): log_message("Team {} has size >= target {} and other options are available. " "Continuing search...".format(team, target_team_size)) continue player = merge_candidates.get() if player.team_id == team.id: continue log_message("Merging player {} from team {} into team {}.".format( player, player.team_id, team.id)) player.team_id = team.id team.size = team.size + 1 gamedb.save(team) gamedb.save(player) # notify player of new team assignment engine.add_event(events.NotifyTeamReassignmentEvent(game_id=self._game_id, game_options=self._options, player=player, team=team))
def _run_single_tribe_council(self, winning_teams: List[Team], losing_teams: List[Team], gamedb: Database, engine: Engine): # announce winner and tribal council for losing teams gamedb.clear_votes() engine.add_event(events.NotifySingleTribeCouncilEvent( game_id=self._game_id, game_options=self._options, winning_teams=winning_teams, losing_teams=losing_teams)) tribal_council_start_timestamp = _unixtime() # wait for votes while (((_unixtime() - tribal_council_start_timestamp) < self._options.single_tribe_council_time_sec) and not self._stop.is_set()): log_message("Waiting for tribal council to end.") time.sleep(self._options.game_wait_sleep_interval_sec) # count votes for team in losing_teams: voted_out_player = self._get_voted_out_player( team=team, gamedb=gamedb) if voted_out_player: gamedb.deactivate_player(player=voted_out_player) log_message("Deactivated player {}.".format(voted_out_player)) engine.add_event(events.NotifyPlayerVotedOutEvent(game_id=self._game_id, game_options=self._options, player=voted_out_player)) else: log_message("For some reason no one got voted out...") log_message("Players = {}.".format( pprint.pformat(gamedb.list_players(from_team=team)))) # notify all players of what happened at tribal council engine.add_event( events.NotifyTribalCouncilCompletionEvent(game_id=self._game_id, game_options=self._options))
def _get_next_challenge(self, gamedb: Database) -> Challenge: available_challenge_count = 0 while available_challenge_count == 0 and not self._stop_event.is_set(): log_message("Waiting for next challenge to become available.") time.sleep(self._options.game_wait_sleep_interval_sec) available_challenges = gamedb.list_challenges( challenge_completed_predicate_value=False) available_challenge_count = len(available_challenges) challenge = available_challenges[0] # return serializable challenge since this gets placed on the event queue. return Challenge(id=challenge.id, name=challenge.name, message=challenge.message, complete=challenge.complete)
def play(self, tribe1: Tribe, tribe2: Tribe, gamedb: Database, engine: Engine) -> Player: self._wait_for_game_start_time() last_tribe_standing = self._play_multi_tribe(tribe1=tribe1, tribe2=tribe2, gamedb=gamedb, engine=engine) log_message( message="Last tribe standing is {}.".format(last_tribe_standing), game_id=self._game_id) last_team_standing = self._play_single_tribe(tribe=last_tribe_standing, gamedb=gamedb, engine=engine) log_message( message="Last team standing is {}.".format(last_team_standing), game_id=self._game_id) finalists = self._play_single_team(team=last_team_standing, gamedb=gamedb, engine=engine) log_message(message="Finalists are {}.".format( pprint.pformat(finalists)), game_id=self._game_id) winner = self._run_finalist_tribe_council(finalists=finalists, gamedb=gamedb, engine=engine) log_message(message="Winner is {}.".format(winner), game_id=self._game_id) engine.stop() return winner
def send_bulk_sms(self, message: str, recipient_addresses: Iterable[str]) -> None: notification = self._client.notify.services( self._notify_service_sid).notifications.create( to_binding=[ json.dumps({ 'binding_type': 'sms', 'address': self._normalize_sms_address(address) }) for address in recipient_addresses ], body=self._normalize_sms_message(message)) log_message(message=str(notification.sid), game_id=self._game_id, additional_tags={"phone_number": self._phone_number})
def _notify_players(self, game_id: Text, players: list, message: Text): twilio = self._get_sms_notifier(game_id=game_id) # iterate over players and get their phone numbers recipient_phone_numbers = list( map(lambda player: player.to_dict().get("phone_number"), players)) # filter out players with no phone number filtered_phone_numbers = list( filter(lambda number: not not number, recipient_phone_numbers)) twilio.send_bulk_sms(message=message, recipient_addresses=filtered_phone_numbers) log_message(message="Notified players with message:{}".format(message), game_id=game_id)
def _wait_for_game_start_time(self) -> None: if self._options.game_clock_mode == GameClockMode.SYNC: game_start_time_sec = _unixtime( ) + self._options.game_schedule.localized_time_delta_sec( end_time=self._options.game_schedule.game_start_time) while ((_unixtime() < game_start_time_sec) and not self._stop_event.is_set() and not self._wait_for_game_start_event.is_set()): log_message("Waiting until {} for game start.".format( game_start_time_sec)) time.sleep(self._options.game_wait_sleep_interval_sec) elif self._options.game_clock_mode == GameClockMode.ASYNC: # start immediately. log_message("Initiating game {} with timing mode.".format( self._options.game_clock_mode))
def put_fn(self, event: SMSEvent) -> None: try: # TODO(brandon) add retry logic and error handling. log_message(message="Putting {} on queue {}.".format( event.to_json(), self._url), game_id=self.game_id) response = self._client.send_message(QueueUrl=self._url, MessageBody=event.to_json(), MessageGroupId=event.game_id, MessageDeduplicationId=str( uuid.uuid4())) except Exception as e: log_message( messages= f'put_fn failed for event {event} with exception {str(e)}.')
def _wait_for_challenge_end_time(self, challenge: Challenge) -> None: if self._options.game_clock_mode == GameClockMode.SYNC: challenge_end_time_sec = _unixtime( ) + self._options.game_schedule.localized_time_delta_sec( end_time=self._options.game_schedule.daily_challenge_end_time) while not self._stop_event.is_set( ) and not self._wait_for_challenge_end_event.is_set(): log_message( "Waiting until {} for daily challenge start.".format( challenge_end_time_sec)) time.sleep(self._options.game_wait_sleep_interval_sec) elif self._options.game_clock_mode == GameClockMode.ASYNC: log_message( f"Waiting {self._options.game_wait_sleep_interval_sec}s for challenge to {str(challenge)} to end..." ) time.sleep(self._options.game_wait_sleep_interval_sec)
def _merge_tribes(self, tribe1: Tribe, tribe2: Tribe, new_tribe_name: Text, gamedb: Database, engine: Engine) -> Tribe: log_message(message=f"Merging tribes into {new_tribe_name}.") with engine: new_tribe = gamedb.tribe(name=new_tribe_name) gamedb.batch_update_tribe(from_tribe=tribe1, to_tribe=new_tribe) gamedb.batch_update_tribe(from_tribe=tribe2, to_tribe=new_tribe) # after tribes merge, sweep the teams to ensure no size of 2 self._merge_teams(target_team_size=self._options.target_team_size, tribe=new_tribe, gamedb=gamedb, engine=engine) game = gamedb.game_from_id(gamedb.get_game_id()) game.count_tribes = 1 gamedb.save(game) return new_tribe
def _set_game_has_started(self, game_snap: DocumentSnapshot, game: Game, value: bool = True): field_updates = {'game_has_started': value} try: game_snap.reference.update(field_updates) log_message( message="Set game_has_started field to {}".format(value), game_id=game._game_id) except Exception as e: log_message( message= "Error setting game document game_has_started field to {}: {}". format(value, e), game_id=game._game_id) raise RuntimeError(str(e))
def _wait_for_tribal_council_start_time(self) -> None: if self._options.game_clock_mode == GameClockMode.SYNC: tribal_council_start_time_sec = _unixtime( ) + self._options.game_schedule.localized_time_delta_sec( end_time=self._options.game_schedule. daily_tribal_council_start_time) while ((_unixtime() < tribal_council_start_time_sec) and not self._stop_event.is_set() and not self._wait_for_tribal_council_start_event.is_set()): log_message("Waiting until {} for tribal council.".format( tribal_council_start_time_sec)) time.sleep(self._options.game_wait_sleep_interval_sec) elif self._options.game_clock_mode == GameClockMode.ASYNC: # start immediately. log_message( "Initiating tribal council in {} game timing mode.".format( self._options.game_clock_mode)) return
def _cancel_game(self, game_snap: DocumentSnapshot, players: list, reason: str = "insufficient players") -> None: # Cancel the game game_dict = game_snap.to_dict() field_updates = { 'to_be_deleted': True, } game_snap.reference.update(field_updates) log_message(message="Cancelled the game (set to_be_deleted flag)", game_id=game_dict.get("id")) notif_message = messages.NOTIFY_GAME_CANCELLED_EVENT_MSG_FMT.format( header=messages.game_sms_header(hashtag=game_dict.get('hashtag')), game=game_dict.get("hashtag"), reason=reason) self._notify_players(game_id=game_dict.get("id"), players=players, message=notif_message)
def get(self) -> SMSEvent: response = self._client.receive_message(QueueUrl=self._url, MessageAttributeNames=[ 'string', ], MaxNumberOfMessages=1, WaitTimeSeconds=20) if 'Messages' in response: message = response['Messages'][0] message_body = message['Body'] log_message(message='Received event with message body {}'.format( message_body), game_id=self.game_id) self._delete_message(message['ReceiptHandle']) return SMSEvent.from_json(json_text=message_body, game_options=self._game_options) else: raise EventQueueError('Queue empty.')
def _run_single_team_council(self, team: Team, losing_players: List[Player], gamedb: Database, engine: Engine): self._wait_for_tribal_council_start_time() # announce winner and tribal council for losing teams gamedb.clear_votes() winning_players = [ player for player in gamedb.list_players(from_team=team) if player not in losing_players ] if len(winning_players) > 0: winning_player = winning_players[0] else: engine.stop() raise GameError( "Unable to determine a winning player for the challenge. Have any entries been submitted?" ) engine.add_event( events.NotifySingleTeamCouncilEvent(game_id=self._game_id, game_options=self._options, winning_player=winning_player, losing_players=losing_players)) self._wait_for_tribal_council_end_time() # count votes voted_out_player = self._get_voted_out_player(team=team, gamedb=gamedb) if voted_out_player: gamedb.deactivate_player(player=voted_out_player) log_message( message="Deactivated player {}.".format(voted_out_player), game_id=self._game_id) engine.add_event( events.NotifyPlayerVotedOutEvent(game_id=self._game_id, game_options=self._options, player=voted_out_player)) # notify all players of what happened at tribal council engine.add_event( events.NotifyTribalCouncilCompletionEvent( game_id=self._game_id, game_options=self._options))
def _run_challenge(self, challenge: Challenge, gamedb: Database, engine: Engine): # wait for challenge to begin while (_unixtime() < challenge.start_timestamp) and not self._stop.is_set(): log_message("Waiting {}s for challenge to {} to begin.".format( challenge.start_timestamp - _unixtime(), challenge)) time.sleep(self._options.game_wait_sleep_interval_sec) # notify players engine.add_event( events.NotifyTribalChallengeEvent(game_id=self._game_id, game_options=self._options, challenge=challenge)) # wait for challenge to end while (_unixtime() < challenge.end_timestamp) and not self._stop.is_set(): log_message("Waiting {}s for challenge to {} to end.".format( challenge.end_timestamp - _unixtime(), challenge)) time.sleep(self._options.game_wait_sleep_interval_sec) challenge.complete = True gamedb.save(challenge)
def _wait_for_tribal_council_end_time(self) -> None: if self._options.game_clock_mode == GameClockMode.SYNC: tribal_council_end_time_sec = _unixtime( ) + self._options.game_schedule.localized_time_delta_sec( end_time=self._options.game_schedule. daily_tribal_council_end_time) while ((_unixtime() < tribal_council_end_time_sec) and not self._stop_event.is_set() and not self._wait_for_tribal_council_start_event.is_set()): log_message("Waiting until {} for tribal council.".format( tribal_council_end_time_sec)) time.sleep(self._options.game_wait_sleep_interval_sec) elif self._options.game_clock_mode == GameClockMode.ASYNC: tribal_council_start_timestamp = _unixtime() while (((_unixtime() - tribal_council_start_timestamp) < self._options.tribe_council_time_sec) and not self._stop_event.is_set() and not self._wait_for_tribal_council_end_event.is_set()): log_message("Waiting for tribal council to end.") time.sleep(self._options.game_wait_sleep_interval_sec)
def _do_work_fn(self) -> None: event = None notifier = self._get_sms_notifier() queue = self._output_events while not self._stop.is_set(): try: # leave events on the queue until the critical section lock # is released. this prevents async workers from acting on event messages # before the database is reconciled by the main game thread. critical # for periods of large mutations like team and tribe merges. if not self._engine_locked(): event = queue.get() game_id = "" if hasattr(event, "game_id"): game_id = event.game_id log_message( message='Engine worker processing event {}'.format( event.to_json()), game_id=self.game_id) notifier.send(sms_event_messages=event.messages( gamedb=self._gamedb)) except EventQueueError: pass except Exception as e: # TODO(brandon): we need to save this exception to the main thread and # cancel the game, otherwise it will crash. log_message( message=f'Engine worker failed with exception {str(e)} {traceback.format_stack()}.', game_id=self.game_id) self.stop() raise log_message(message='Shutting down workder thread {}.'.format( threading.current_thread().ident), game_id=self.game_id)