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 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: query = session.query(QueuePlayer).filter(QueuePlayer.player_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: query = query.filter(QueuePlayer.player_server_id == server_id) # This removes the player only from the given channel (cancellation) if channel_id: query = query.filter(QueuePlayer.channel_id == channel_id) query.delete(synchronize_session=False)
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 reset the queue # We put 20 people in the queue for i in range(0, 20): game_queue.add_player(i, roles_list[i % 5], ctx.channel.id, ctx.guild.id, name=str(i)) # 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 ) game = matchmaking_logic.find_best_game(GameQueue(ctx.channel.id)) with session_scope() as session: session.add(game) 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=int(random.random() * 9), server_id=ctx.guild.id ) await ctx.send("100 games have been created in the database") await queue_channel_handler.update_server_queues(bot=self.bot, server_id=ctx.guild.id)
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 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_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 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_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 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)
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) == int(os.environ["INHOUSE_BOT_QUEUE_SIZE"]) 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) )
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_server_queues(bot=self.bot, server_id=ctx.guild.id)
async def stats(self, ctx: commands.Context): """ Returns your rank, MMR, and games played Example: !rank """ # TODO LOW PRIO Make it not output the server only in DMs, otherwise filter on the server with session_scope() as session: rating_objects = (session.query( PlayerRating, 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) table = [] for row in rating_objects: rank = (session.query( func.count()).select_from(PlayerRating).filter( PlayerRating.player_server_id == row.player_server_id). filter(PlayerRating.role == row.role).filter( PlayerRating.mmr > row.mmr)).first()[0] table.append([ self.bot.get_guild(row.player_server_id).name, row.role, inflect_engine.ordinal(rank + 1), round(row.mmr, 2), row.count, f"{int(row.wins / row.count * 100)}%", ]) # Sorting the table by games played table = sorted(table, key=lambda x: -x[4]) # Added afterwards to allow sorting first table.insert(0, ["Server", "Role", "Rank", "MMR", "Games", "Win%"]) await ctx.send(f"Ranks for {ctx.author.display_name}" f'```{tabulate(table, headers="firstrow")}```')
async def won( self, ctx: commands.Context, ): """ Scores your last game as a win Will require validation from at least 6 players in the game Example: !won """ # TODO MED PRIO ONLY ONE ONGOING CANCEL/SCORING MESSAGE PER GAME 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( "Your last game seem to have already been scored\n" "If there was an issue, please contact an admin") return win_validation_message = await ctx.send( f"{ctx.author.display_name} wants to score game {game.id} as a win for {participant.side}\n" f"{', '.join([f'<@{discord_id}>' for discord_id in game.player_ids_list])} can validate the result\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, timeout=60, ) 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" )) matchmaking_logic.score_game_from_winning_player( player_id=ctx.author.id, server_id=ctx.guild.id)
def remove_queue_channel(self, channel_id): game_queue.reset_queue(channel_id) with session_scope() as session: reset_queue(channel_id) 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 ]
async def ranking(self, ctx: commands.Context, role: RoleConverter() = None): """ Displays the top players on the server A role can be supplied to only display the ranking for this role Example: !ranking !ranking mid """ 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 == ctx.guild.id).group_by( Player, PlayerRating).order_by(PlayerRating.mmr.desc())) if role: ratings = ratings.filter(PlayerRating.role == role) ratings = ratings.limit(100).all() if not ratings: await ctx.send("No games played yet") return pages = menus.MenuPages( source=RankingPagesSource( ratings, self.bot, embed_name_suffix= f"on {ctx.guild.name}{f' - {get_role_emoji(role)}' if role else ''}", ), clear_reactions_after=True, ) await pages.start(ctx)
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 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) )
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_server_queues(bot=self.bot, server_id=ctx.guild.id)
def __init__(self): # We reload the queue channels from the database on restart with session_scope() as session: session.expire_on_commit = False self._queue_channels = (session.query( ChannelInformation.id, ChannelInformation.server_id).filter( ChannelInformation.channel_type == "QUEUE").all()) # channel_id -> GameQueue, to know when there were updates? self._queue_cache = {} # Helps untag older message that needs to be deleted self.latest_queue_message_ids = {} # IDs of messages we do not want to delete self.queue_related_messages_ids = set()
async def cancel( self, ctx: commands.Context, ): """ Cancels your ongoing game Will require validation from at least 6 players in the game Example: !cancel """ # TODO MED PRIO ONLY ONE ONGOING CANCEL/SCORING MESSAGE PER GAME 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 cancel_validation_message = await ctx.send( f"{ctx.author.display_name} wants to cancel game {game.id}\n" f"{', '.join([f'<@{discord_id}>' for discord_id in game.player_ids_list])} can cancel the game\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, timeout=60, ) if not validated: await ctx.send(f"Game {game.id} was not cancelled") else: session.delete(game) queue_channel_handler.mark_queue_related_message( await ctx.send(f"Game {game.id} was cancelled"))
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 = session.query(QueuePlayer).filter(QueuePlayer.player_id == player_id) # If given a channel ID (when the user calls !leave), we filter if channel_id: query = query.filter(QueuePlayer.channel_id == channel_id) query.delete(synchronize_session=False)
async def history(self, ctx: commands.Context): # TODO LOW PRIO Add an @ user for admins """ Displays your games history Example: !history """ 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 game(self, ctx: commands.Context): """ Creating a fake game in the database with players 0 to 8 and the ctx author """ # We reset the queue # We put 9 people in the queue for i in range(0, 9): game_queue.add_player(i, roles_list[i % 5], ctx.channel.id, ctx.guild.id, name=str(i)) game_queue.add_player( ctx.author.id, roles_list[4], ctx.channel.id, ctx.guild.id, name=ctx.author.display_name ) game = matchmaking_logic.find_best_game(GameQueue(ctx.channel.id)) with session_scope() as session: session.add(game) msg = await ctx.send("The queue has been reset, filled again, and a game created (with no winner)") game_queue.start_ready_check([i for i in range(0, 9)] + [ctx.author.id], ctx.channel.id, msg.id) game_queue.validate_ready_check(msg.id) await queue_channel_handler.update_server_queues(bot=self.bot, server_id=ctx.guild.id)
async def champion(self, ctx: commands.Context, champion_id: ChampionNameConverter(), game_id: int = None): """ Saves the champion you used in your last game Older games can be filled with !champion champion_name game_id You can find the ID of the games you played with !history Example: !champion riven !champion riven 1 """ 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_id game_id = game.id await ctx.send( f"Champion for game {game_id} was set to " f"{lol_id_tools.get_name(champion_id, object_type='champion')} for {ctx.author.display_name}" )
async def run_matchmaking_logic( self, ctx: commands.Context, ): """ Runs the matchmaking logic in the channel defined by the context Should only be called inside guilds """ queue = game_queue.GameQueue(ctx.channel.id) game = matchmaking_logic.find_best_game(queue) if not game: return elif game and game.matchmaking_score < 0.2: embed = Embed( title="📢 Game found 📢", description= f"Blue side expected winrate is {game.blue_expected_winrate * 100:.1f}%\n" "If you are ready to play, press ✅\n" "If you cannot play, press �", ) embed = game.add_game_field(embed, []) # We notify the players and send the message ready_check_message = await ctx.send( content= f"||{' '.join([f'<@{discord_id}>' for discord_id in game.player_ids_list])}||", embed=embed, ) # Because it still takes some time, we *directly* add it to the *no delete* list # That’s dirty and should likely be handled in a better way (maybe by *not* using purge # and choosing what to delete instead, but it also has its issues) # TODO HIGH PRIO Think about saving a "messages to not delete list" in the queue handler memory and # use it in the cog listener, and automatically delete any other one after 5s? should work better (less bugs) queue_channel_handler.mark_queue_related_message( ready_check_message) # We mark the ready check as ongoing (which will be used to the queue) game_queue.start_ready_check( player_ids=game.player_ids_list, channel_id=ctx.channel.id, ready_check_message_id=ready_check_message.id, ) # We update the queue in all channels await queue_channel_handler.update_server_queues( bot=self.bot, server_id=ctx.guild.id) # And then we wait for the validation ready, players_to_drop = await checkmark_validation( bot=self.bot, message=ready_check_message, validating_players_ids=game.player_ids_list, validation_threshold=10, timeout=3 * 60, game=game, ) if ready is True: # We drop all 10 players from the queue game_queue.validate_ready_check(ready_check_message.id) # We commit the game to the database (without a winner) with session_scope() as session: session.add(game) embed = Embed( title="📢 Game accepted 📢", description= f"Game {game.id} has been validated and added to the database\n" f"Once the game has been played, one of the winners can score it with `!won`\n" f"If you wish to cancel the game, use `!cancel`", ) embed = game.add_game_field(embed) queue_channel_handler.mark_queue_related_message( await ctx.send(embed=embed, )) elif ready is False: # We remove the player who cancelled game_queue.cancel_ready_check( ready_check_id=ready_check_message.id, ids_to_drop=players_to_drop, channel_id=ctx.channel.id, ) queue_channel_handler.mark_queue_related_message(await ctx.send( f"A player cancelled the game and was removed from the queue\n" f"All other players have been put back in the queue")) # We restart the matchmaking logic await self.run_matchmaking_logic(ctx) elif ready is None: # We remove the timed out players from *all* channels (hence giving server id) game_queue.cancel_ready_check( ready_check_id=ready_check_message.id, ids_to_drop=players_to_drop, server_id=ctx.guild.id, ) queue_channel_handler.mark_queue_related_message(await ctx.send( "The check timed out and players who did not answer have been dropped from all queues" )) # We restart the matchmaking logic await self.run_matchmaking_logic(ctx) elif game and game.matchmaking_score >= 0.2: # One side has over 70% predicted winrate, we do not start anything await ctx.send( f"The best match found had a side with a {(.5 + game.matchmaking_score)*100:.1f}%" f" predicted winrate and was not started")