def test_matchmaking_logic(): game_queue.reset_queue() # We queue for everything except the red support for player_id in range(0, 9): game_queue.add_player(player_id, roles_list[player_id % 5], 0, 0) assert not find_best_game(GameQueue(0)) # We add the last player game_queue.add_player(9, "SUP", 0, 0) game = find_best_game(GameQueue(0)) assert game # We commit the game to the database with session_scope() as session: session.add(game) # We say player 0 won his last game on server 0 score_game_from_winning_player(0, 0) # We check that everything got changed with session_scope() as session: # We recreate the game object so it’s associated with this new session game = session.query(Game).order_by(Game.start.desc()).first() for side, role in game.participants: participant = game.participants[side, role] assert participant.player.ratings[role].trueskill_mu != 25
def add_player(player_id: int, role: str, channel_id: int, server_id: int = None, name: str = None): # Just in case assert role in roles_list with session_scope() as session: game, participant = get_last_game(player_id, server_id, session) if game and not game.winner: raise PlayerInGame # Then check if the player is in a ready-check if is_in_ready_check(player_id, session): raise PlayerInReadyCheck # This is where we add new Players to the server # This is also useful to automatically update name changes session.merge(Player(id=player_id, server_id=server_id, name=name)) # Finally, we actually add the player to the queue queue_player = QueuePlayer( channel_id=channel_id, player_id=player_id, player_server_id=server_id, role=role, queue_time=datetime.now(), ) # We merge for simplicity (allows players to re-queue for the same role) session.merge(queue_player)
def get_server_ratings(server_id: int, role: str = None, limit=100): with session_scope() as session: session.expire_on_commit = False ratings = ( session.query( Player, PlayerRating.player_server_id, PlayerRating.mmr, PlayerRating.role, func.count().label("count"), (sqlalchemy.func.sum( (Game.winner == GameParticipant.side).cast( sqlalchemy.Integer)) ).label("wins"), # A bit verbose for sure ).select_from(Player).join(PlayerRating).join(GameParticipant). join(Game).filter(Player.server_id == server_id).filter( Game.winner != None) # No currently running game .group_by(Player, PlayerRating).order_by(PlayerRating.mmr.desc())) if role: ratings = ratings.filter(PlayerRating.role == role) ratings = ratings.limit(limit).all() return ratings
async def cancel(self, ctx: commands.Context, member: discord.Member): """ Cancels the user’s ongoing game Only works if the game has not been scored yet """ with session_scope() as session: game, participant = get_last_game(player_id=member.id, server_id=ctx.guild.id, session=session) if game and game.winner: await ctx.send( "The game has already been scored and cannot be canceled anymore" ) return session.delete(game) await ctx.send( f"{member.display_name}’s ongoing game was cancelled and deleted from the database" ) await queue_channel_handler.update_queue_channels( bot=self.bot, server_id=ctx.guild.id) await remove_voice_channels(ctx, game)
async def games(self, ctx: commands.Context): """ Creates 100 games in the database with random results """ game_queue.reset_queue() for game_count in range(100): # We add the context creator as well game_queue.add_player( ctx.author.id, roles_list[4], ctx.channel.id, ctx.guild.id, name=ctx.author.display_name ) # We put 15 people in the queue, but only the first ones should get picked for i in range(0, 15): game_queue.add_player(i, roles_list[i % 5], ctx.channel.id, ctx.guild.id, name=str(i)) game = matchmaking_logic.find_best_game(GameQueue(ctx.channel.id)) with session_scope() as session: session.add(game) winner = game.player_ids_list[int(random.random() * 10)] game_queue.start_ready_check([i for i in range(0, 9)] + [ctx.author.id], ctx.channel.id, 0) game_queue.validate_ready_check(0) matchmaking_logic.score_game_from_winning_player(player_id=winner, server_id=ctx.guild.id) await ranking_channel_handler.update_ranking_channels(self.bot, ctx.guild.id) await ctx.send("100 games have been created in the database") await queue_channel_handler.update_queue_channels(bot=self.bot, server_id=ctx.guild.id)
async def champion(self, ctx: commands.Context, champion_name: ChampionNameConverter(), game_id: int = None): with session_scope() as session: if not game_id: game, participant = get_last_game(player_id=ctx.author.id, server_id=ctx.guild.id, session=session) else: game, participant = (session.query( Game, GameParticipant).select_from(Game).join( GameParticipant).filter(Game.id == game_id).filter( GameParticipant.player_id == ctx.author.id) ).one_or_none() # We write down the champion participant.champion_id = champion_name game_id = game.id await ctx.send( f"Champion for game {game_id} was set to " f"{lol_id_tools.get_name(champion_name, object_type='champion')} for {ctx.author.display_name}" )
async def history(self, ctx: commands.Context): # TODO LOW PRIO Add an @ user for admins with session_scope() as session: session.expire_on_commit = False game_participant_query = (session.query( Game, GameParticipant).select_from(Game).join( GameParticipant).filter( GameParticipant.player_id == ctx.author.id).order_by( Game.start.desc())) # If we’re on a server, we only show games played on that server if ctx.guild: game_participant_query = game_participant_query.filter( Game.server_id == ctx.guild.id) game_participant_list = game_participant_query.limit(100).all() if not game_participant_list: await ctx.send("No games found") return pages = menus.MenuPages( source=HistoryPagesSource( game_participant_list, self.bot, player_name=ctx.author.display_name, is_dms=True if not ctx.guild else False, ), clear_reactions_after=True, ) await pages.start(ctx)
async def won( self, ctx: commands.Context, ): with session_scope() as session: # Get the latest game game, participant = get_last_game( player_id=ctx.author.id, server_id=ctx.guild.id, session=session ) if not game: await ctx.send("You have not played a game on this server yet") return elif game and game.winner: await ctx.send( "Your last game seem to have already been scored\n" "If there was an issue, please contact an admin" ) return elif game.id in self.games_getting_scored_ids: await ctx.send("There is already a scoring or cancellation message active for this game") return else: self.games_getting_scored_ids.add(game.id) win_validation_message = await ctx.send( f"{game.players_ping}" f"{ctx.author.display_name} wants to score game {game.id} as a win for {participant.side}\n" f"Result will be validated once 6 players from the game press ✅" ) validated, players_who_refused = await checkmark_validation( bot=self.bot, message=win_validation_message, validating_players_ids=game.player_ids_list, validation_threshold=6, ) # Whatever happens, we’re not scoring it anymore if we get here self.games_getting_scored_ids.remove(game.id) if not validated: await ctx.send("Score input was either cancelled or timed out") return # If we get there, the score was validated and we can simply update the game and the ratings queue_channel_handler.mark_queue_related_message( await ctx.send( f"Game {game.id} has been scored as a win for {participant.side} and ratings have been updated" ) ) categoria = discord.utils.get(ctx.guild.categories, name="⚔ Game-%s" % str(game.id)) for channel in categoria.channels: await channel.delete() await categoria.delete() matchmaking_logic.score_game_from_winning_player(player_id=ctx.author.id, server_id=ctx.guild.id) await ranking_channel_handler.update_ranking_channels(self.bot, ctx.guild.id)
def remove_player(player_id: int, channel_id: int = None): """ Removes the player from the queue in all roles in the channel If no channel id is given, drop him from *all* queues, cross-server """ with session_scope() as session: # First, check if he’s in a ready-check. if ( is_in_ready_check(player_id, session) and channel_id ): # If we have no channel ID, it’s an !admin reset and we bypass the issue here raise PlayerInReadyCheck # We select the player’s rows query_player = session.query(QueuePlayer).filter( QueuePlayer.player_id == player_id) query_duos = session.query(QueuePlayer).filter( QueuePlayer.duo_id == player_id) # If given a channel ID (when the user calls !leave), we filter if channel_id: query_player = query_player.filter( QueuePlayer.channel_id == channel_id) query_duos = query_duos.filter( QueuePlayer.channel_id == channel_id) query_player.delete(synchronize_session=False) query_duos.update({"duo_id": None}, synchronize_session=False)
def __init__(self): with session_scope() as session: session.expire_on_commit = False self._ranking_channels = (session.query( ChannelInformation.id, ChannelInformation.server_id).filter( ChannelInformation.channel_type == "RANKING").all())
async def cancel( self, ctx: commands.Context, ): """ Cancels your ongoing game Will require validation from at least 6 players in the game Example: !cancel """ with session_scope() as session: # Get the latest game game, participant = get_last_game(player_id=ctx.author.id, server_id=ctx.guild.id, session=session) if game and game.winner: await ctx.send( "It does not look like you are part of an ongoing game") return elif game.id in self.games_getting_scored_ids: await ctx.send( "There is already a scoring or cancellation message active for this game" ) return else: self.games_getting_scored_ids.add(game.id) cancel_validation_message = await ctx.send( f"{game.players_ping}" f"{ctx.author.display_name} wants to cancel game {game.id}\n" f"Game will be canceled once 6 players from the game press ✅") validated, players_who_refused = await checkmark_validation( bot=self.bot, message=cancel_validation_message, validating_players_ids=game.player_ids_list, validation_threshold=6, ) self.games_getting_scored_ids.remove(game.id) if not validated: await ctx.send(f"Game {game.id} was not cancelled") else: for participant in game.participants.values(): self.players_whose_last_game_got_cancelled[ participant.player_id] = datetime.now() session.delete(game) queue_channel_handler.mark_queue_related_message( await ctx.send(f"Game {game.id} was cancelled"))
def cancel_all_ready_checks(): """ Cancels all ready checks, used when restarting the bot """ with session_scope() as session: # We put all ready_check_id to None session.query(QueuePlayer).update({"ready_check_id": None}, synchronize_session=False)
def remove_players(player_ids: Set[int], channel_id: int): """ Removes all players from the queue in all roles in the channel, without any checks """ with session_scope() as session: (session.query(QueuePlayer).filter( QueuePlayer.channel_id == channel_id).filter( QueuePlayer.player_id.in_(player_ids)).delete( synchronize_session=False))
def unmark_queue_channel(self, channel_id): game_queue.reset_queue(channel_id) with session_scope() as session: channel_query = session.query(ChannelInformation).filter(ChannelInformation.id == channel_id) channel_query.delete(synchronize_session=False) self._queue_channels = [c for c in self._queue_channels if c.id != channel_id] queue_logger.info(f"Unmarked {channel_id} as a queue channel")
def unmark_ranking_channel(self, channel_id): with session_scope() as session: channel_query = session.query(ChannelInformation).filter( ChannelInformation.id == channel_id) channel_query.delete(synchronize_session=False) self._ranking_channels = [ c for c in self._ranking_channels if c.id != channel_id ]
def score_game_from_winning_player(player_id: int, server_id: int): """ Scores the last game of the player on the server as a *win* """ with session_scope() as session: game, participant = get_last_game(player_id, server_id, session) game.winner = participant.side update_trueskill(game, session)
def remove_duo(player_id: int, channel_id: int): # Removes duos for all roles for this player in this channel # This could be called during a ready-check but it shouldn’t be too much of an issue with session_scope() as session: (session.query(QueuePlayer).filter( QueuePlayer.channel_id == channel_id).filter( sqlalchemy.or_(QueuePlayer.duo_id == player_id, QueuePlayer.player_id == player_id)).update( {"duo_id": None}, synchronize_session=False))
async def mmr_history(self, ctx: commands.Context): """ Displays a graph of your MMR history over the past month """ date_start = datetime.now() - timedelta(hours=24 * 30) with session_scope() as session: participants = ( session.query( Game.start, GameParticipant.role, GameParticipant.mmr, PlayerRating.mmr.label("latest_mmr"), ) .select_from(Game) .join(GameParticipant) .join(PlayerRating) # Join on rating first to select the right role .join(Player) .filter(GameParticipant.player_id == ctx.author.id) .filter(Game.start > date_start) .order_by(Game.start.asc()) ) mmr_history = defaultdict(lambda: {"dates": [], "mmr": []}) latest_role_mmr = {} for row in participants: mmr_history[row.role]["dates"].append(row.start) mmr_history[row.role]["mmr"].append(row.mmr) latest_role_mmr[row.role] = row.latest_mmr legend = [] for role in mmr_history: # We add a data point at the current timestamp with the player’s current MMR mmr_history[role]["dates"].append(datetime.now()) mmr_history[role]["mmr"].append(latest_role_mmr[role]) plt.plot(mmr_history[role]["dates"], mmr_history[role]["mmr"]) legend.append(role) plt.legend(legend) plt.title(f"MMR variation in the last month for {ctx.author.display_name}") mplcyberpunk.add_glow_effects() # This looks to be unnecessary verbose with all the closing by hand, I should take a look with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp: plt.savefig(temp.name) file = discord.File(temp.name, filename=temp.name) await ctx.send(file=file) plt.close() temp.close()
def get_active_queues() -> List[int]: """ Returns a list of channel IDs where there is a queue ongoing """ with session_scope() as session: output = [ r.channel_id for r in session.query( QueuePlayer.channel_id).group_by(QueuePlayer.channel_id) ] return output
def mark_ranking_channel(self, channel_id, server_id): """ Marks the given channel + server combo as a queue """ channel = ChannelInformation(id=channel_id, server_id=server_id, channel_type="RANKING") with session_scope() as session: session.merge(channel) self._ranking_channels.append(channel)
def mark_queue_channel(self, channel_id, server_id): """ Marks the given channel + server combo as a queue """ channel = ChannelInformation(id=channel_id, server_id=server_id, channel_type="QUEUE") with session_scope() as session: session.merge(channel) self._queue_channels.append(channel) queue_logger.info(f"Marked {channel_id} as a queue channel")
async def stats(self, ctx: commands.Context): """ Returns your rank, MMR, and games played Example: !rank """ with session_scope() as session: rating_objects = ( session.query( PlayerRating, sqlalchemy.func.count().label("count"), ( sqlalchemy.func.sum((Game.winner == GameParticipant.side).cast(sqlalchemy.Integer)) ).label("wins"), ) .select_from(PlayerRating) .join(GameParticipant) .join(Game) .filter(PlayerRating.player_id == ctx.author.id) .group_by(PlayerRating) ) if ctx.guild: rating_objects = rating_objects.filter(PlayerRating.player_server_id == ctx.guild.id) rows = [] for row in sorted(rating_objects.all(), key=lambda r: -r.count): # TODO LOW PRIO Make that a subquery rank = ( session.query(sqlalchemy.func.count()) .select_from(PlayerRating) .filter(PlayerRating.player_server_id == row.PlayerRating.player_server_id) .filter(PlayerRating.role == row.PlayerRating.role) .filter(PlayerRating.mmr > row.PlayerRating.mmr) ).first()[0] rank_str = get_rank_emoji(rank) row_string = ( f"{f'{self.bot.get_guild(row.PlayerRating.player_server_id).name} ' if not ctx.guild else ''}" f"{get_role_emoji(row.PlayerRating.role)} " f"{rank_str} " f"`{int(row.PlayerRating.mmr)} MMR " f"{row.wins}W {row.count-row.wins}L`" ) rows.append(row_string) embed = Embed(title=f"Ranks for {ctx.author.display_name}", description="\n".join(rows)) await ctx.send(embed=embed)
def start_ready_check(player_ids: List[int], channel_id: int, ready_check_message_id: int): # Checking to make sure everything is fine assert len(player_ids) == 10 with session_scope() as session: (session.query(QueuePlayer).filter( QueuePlayer.channel_id == channel_id).filter( QueuePlayer.player_id.in_(player_ids)).update( {"ready_check_id": ready_check_message_id}, synchronize_session=False))
def add_duo( first_player_id: int, first_player_role: str, second_player_id: int, second_player_role: str, channel_id: int, server_id: int = None, first_player_name: str = None, second_player_name: str = None, jump_ahead=False, ): # Marks this group of players and roles as a duo if first_player_role == second_player_role: raise SameRolesForDuo # Just in case, we drop the players from the queue first remove_player(first_player_id, channel_id) remove_player(second_player_id, channel_id) add_player( player_id=first_player_id, role=first_player_role, channel_id=channel_id, server_id=server_id, name=first_player_name, jump_ahead=jump_ahead, ) add_player( player_id=second_player_id, role=second_player_role, channel_id=channel_id, server_id=server_id, name=second_player_name, jump_ahead=jump_ahead, ) with session_scope() as session: # Finally, we add the duos by merging only the newer data (empty fields shouldn’t get merged) first_queue_player = QueuePlayer(player_id=first_player_id, role=first_player_role, channel_id=channel_id, duo_id=second_player_id) second_queue_player = QueuePlayer(player_id=second_player_id, role=second_player_role, channel_id=channel_id, duo_id=first_player_id) # We merge the new information session.merge(first_queue_player) session.merge(second_queue_player)
def daily_jobs(self): """ Runs a timer every 60 seconds, triggering jobs at the appropriate minute mark """ threading.Timer(60, self.daily_jobs).start() now = datetime.now() if now.strftime("%H:%M") == QUEUE_RESET_TIME: with session_scope() as session: server_config = get_server_config(server_id=self.guilds[0].id, session=session) if server_config.config.get('queue_reset'): game_queue.reset_queue() self.loop.create_task(queue_channel_handler.update_queue_channels(bot=self, server_id=None))
async def cancel(self, ctx: commands.Context): """ Scores your last game as a win for (mostly made to be used after !test game) """ with session_scope() as session: game, participant = get_last_game( player_id=ctx.author.id, server_id=ctx.guild.id, session=session ) session.delete(game) await ctx.send(f"{ctx.author.display_name}’s last game was cancelled and deleted from the database") await queue_channel_handler.update_queue_channels(bot=self.bot, server_id=ctx.guild.id)
def validate_ready_check(ready_check_id: int): """ When a ready check is validated, we drop all players from all queues """ with session_scope() as session: player_ids = [ r.player_id for r in session.query(QueuePlayer.player_id).filter( QueuePlayer.ready_check_id == ready_check_id) ] (session.query(QueuePlayer).filter( QueuePlayer.player_id.in_(player_ids)).delete( synchronize_session=False))
def reset_queue(channel_id: Optional[int] = None): """ Resets queue in a specific channel. If channel_id is None, cancels *all* queues. Only for testing purposes. Args: channel_id: channel id of the queue to cancel """ with session_scope() as session: query = session.query(QueuePlayer) if channel_id is not None: query = query.filter(QueuePlayer.channel_id == channel_id) query.delete(synchronize_session=False)
def cancel_ready_check( ready_check_id: int, ids_to_drop: Optional[List[int]], channel_id=None, server_id=None, ): """ Cancels an ongoing ready check by reverting players to ready_check_id=None Drops players in ids_to_drop[] If server_id is not None, drops the player from all queues in the server """ with session_scope() as session: # First, we cancel the ready check for *all* players, even the ones we don’t drop (session.query(QueuePlayer).filter( QueuePlayer.ready_check_id == ready_check_id).update( {"ready_check_id": None}, synchronize_session=False)) if ids_to_drop: # TODO This should be shared with remove_player and not duplicated players_query = session.query(QueuePlayer).filter( QueuePlayer.player_id.in_(ids_to_drop)) duos_query = session.query(QueuePlayer).filter( QueuePlayer.duo_id.in_(ids_to_drop)) if server_id and channel_id: raise Exception( "channel_id and server_id should not be used together here" ) # This removes the player from *all* queues in the server (timeout) if server_id: players_query = players_query.filter( QueuePlayer.player_server_id == server_id) duos_query = duos_query.filter( QueuePlayer.player_server_id == server_id) # This removes the player only from the given channel (cancellation) if channel_id: players_query = players_query.filter( QueuePlayer.channel_id == channel_id) duos_query = duos_query.filter( QueuePlayer.channel_id == channel_id) # Drop the player and remove his duo status with other players players_query.delete(synchronize_session=False) duos_query.update({"duo_id": None}, synchronize_session=False)
def test_duo_matchmaking(): game_queue.reset_queue() # Playing 100 games with random outcomes and making sure 0 and 9 are always on the same team for game_count in range(100): # We queue for everything except the red support for player_id in range(0, 9): game_queue.add_player(player_id, roles_list[player_id % 5], 0, 0, name=str(player_id)) # We add the last player as duo with player 0 game_queue.add_duo( 0, "TOP", 9, "SUP", 0, 0, second_player_name="9", first_player_name="0", ) game = find_best_game(GameQueue(0)) player_0 = next(p for p in game.participants.values() if p.player_id == 0) player_9 = next(p for p in game.participants.values() if p.player_id == 9) assert player_0.side == player_9.side with session_scope() as session: session.add(game) winner = game.player_ids_list[int(random.random() * 10)] game_queue.start_ready_check([i for i in range(0, 10)], 0, 0) game_queue.validate_ready_check(0) score_game_from_winning_player(player_id=winner, server_id=0)