async def remove_player_from_queue(self, player, channel_id=None, ctx=None): """ Removes the given player from queue. If given a channel_id, only removes the player from the given channel. If given a context message, thumbs ups it. Returns a list of [channel_id, role] it was removed from """ session = get_session() query = session.query(QueuePlayer).filter( QueuePlayer.player_id == player.discord_id) if channel_id: query = query.filter(QueuePlayer.channel_id == channel_id) query.delete(synchronize_session=False) session.commit() session.close() logging.info("Player <{}> has been removed from {}".format( player.discord_string, "all queues" if not channel_id else "the <{}> queue".format(channel_id))) if ctx: await ctx.message.add_reaction("👍") await self.send_queue(ctx)
async def admin_score(self, ctx, game_id, winning_team): """ Admin-only way to score an active match. Needs game_id and winner (blue/red) Example: !admin_score 1 blue !admin_score 3 red """ session = get_session() game = session.query(Game).filter(Game.id == game_id).one() if game.winner: await ctx.send( 'It is currently impossible the change the result of a game that was scored because it would ' 'create huge issues with rating recalculations.') return if winning_team not in ['blue', 'red']: await ctx.send('The winning team must be blue or red.\n' 'Example: `!admin_score 1 blue`') return game.winner = winning_team session.commit() game.update_trueskill(session) message = f'Game {game.id} has been scored as a win for {game.winner} and ratings have been updated.' logging.info(message) await ctx.send(message)
async def send_queue(self, ctx: commands.context): """ Deletes the previous queue message and sends a new one in the channel """ channel_id = ctx.channel.id try: old_queue_message = self.latest_queue_message[channel_id] except KeyError: old_queue_message = None # Getting players in queue and not in a ready check session = get_session() players_in_queue = (session.query(QueuePlayer).options( joinedload(QueuePlayer.player)).filter( QueuePlayer.channel_id == ctx.channel.id).filter( QueuePlayer.ready_check == None).all()) session.close() rows = [] for role in roles_list: # We add an empty string list or tabulate gets sad rows.append(f"{get_role_emoji(role)} " + ", ".join(p.player.name for p in players_in_queue if p.role == role)) embed = Embed(colour=discord.colour.Colour.dark_red()) embed.add_field(name="Queue", value="\n".join(rows)) self.latest_queue_message[channel_id] = await ctx.send(embed=embed) # Sequenced that way for smoother scrolling in discord if old_queue_message: await old_queue_message.delete()
async def queue(self, ctx: commands.Context, *, roles): """ Puts you in a queue in the current channel for the specified roles. Roles are top, jungle, mid, bot, and support. Example usage: !queue support !queue mid bot """ player = await self.bot.get_player(ctx) # First, we check if the last game of the player is still ongoing. try: game, participant = player.get_last_game() if not game.winner: await ctx.send( "Your last game looks to be ongoing. " "Please use !won or !lost to inform the result if the game is over.", delete_after=self.bot.short_notice_duration, ) return # This happens if the player has not played a game yet as get_last returns None and can’t be unpacked except TypeError: pass session = get_session() queue_player = (session.query(QueuePlayer).filter( QueuePlayer.player_id == Player.discord_id).filter( QueuePlayer.ready_check != None)).first() session.close() if queue_player: await ctx.send( "It seems you are in a pre-game check. You will be able to queue again once it is over." ) return clean_roles = set() for role in roles.split(" "): clean_role, score = process.extractOne(role, roles_list) if score < 80: continue else: clean_roles.add(clean_role) if not clean_roles: await ctx.send(self.bot.role_not_understood, delete_after=self.bot.warning_duration) return for role in clean_roles: self.add_player_to_queue(player, role, ctx.channel.id) for role in clean_roles: # Dirty code to get the emoji related to the letters await ctx.message.add_reaction(get_role_emoji(role)) await self.matchmaking_process(ctx) await self.send_queue(ctx)
async def ranking(self, ctx: commands.Context, role='all'): """ Returns the top 20 players for the selected role. """ if role == 'all': clean_role = role else: clean_role, score = process.extractOne(role, roles_list) if score < 80: await ctx.send(self.bot.role_not_understood, delete_after=30) return session = get_session() role_ranking = session.query(PlayerRating).order_by( -PlayerRating.mmr).filter(PlayerRating.games > 0) if clean_role != 'all': role_ranking = role_ranking.filter(PlayerRating.role == clean_role) table = [['Rank', 'Name', 'MMR', 'Games'] + ['Role' if clean_role == 'all' else None]] for rank, rating in enumerate(role_ranking.limit(20)): table.append([ inflect_engine.ordinal(rank + 1), rating.player.name, f'{rating.mmr:.1f}', rating.get_games() ] + [rating.role if clean_role == 'all' else None]) await ctx.send(f'Ranking for {clean_role} is:\n' f'```{tabulate(table, headers="firstrow")}```')
def add_player_to_queue(self, player, role, channel_id): if role not in player.ratings: logging.info("Creating a new PlayerRating for <{}> <{}>".format( player.discord_string, role)) new_rating = PlayerRating(player, role) self.bot.players_session.add(new_rating) self.bot.players_session.commit() # This step is required so our player object has access to the rating player = self.bot.players_session.merge(player) # Actually adding the player to the queue # TODO A player can reset his ready_check status that way since it’s not blocking, see if it makes sense session = get_session() # noinspection PyTypeChecker queue_player = QueuePlayer(channel_id=channel_id, player_id=player.discord_id, role=role, ready_check=None) session.merge(queue_player) session.commit() session.close() logging.info( f"Player <{player.discord_string}> has been added to the <{role}><{channel_id}> queue" )
async def reset_queue(self, ctx): """ Admin-only way to reset the queue in the current channel """ session = get_session() query = session.query(QueuePlayer).filter( QueuePlayer.channel_id == ctx.channel.id) query.delete(synchronize_session=False) session.commit() session.close() await self.send_queue(ctx)
async def mmr_history(self, ctx: commands.Context, user_id=None, date_start=None): """Displays a graph of your MMR history over the past month. """ try: player = await self.get_player_with_team_check(ctx, user_id) except PermissionError: return if not date_start: date_start = dateparser.parse("one month ago") else: date_start = dateparser.parse(date_start) session = get_session() # TODO Use the player_rating.game_participant_objects field? participants = (session.query( Game, GameParticipant).join(GameParticipant).filter( GameParticipant.player_id == player.discord_id).filter( Game.date > date_start)) mmr_history = defaultdict(lambda: {"dates": [], "mmr": []}) for game, participant in participants: mmr_history[participant.role]["dates"].append(game.date) mmr_history[participant.role]["mmr"].append(participant.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(player.ratings[role].mmr) 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 {player.name}") mplcyberpunk.add_glow_effects() 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 config(): from inhouse_bot.inhouse_bot import InhouseBot from inhouse_bot.sqlite.sqlite_utils import get_session session = get_session() # We create our bot object, a mock server, a mock channel, 10 mock members, and return our cog for testing bot = InhouseBot() dpytest.configure(bot, 1, 1, 10) config = dpytest.get_config() queue_cog = bot.get_cog('Queue') channel_id = config.channels[0].id ConfigTuple = collections.namedtuple( 'config', ['config', 'bot', 'queue_cog', 'channel_id', 'session']) return ConfigTuple(config, bot, queue_cog, channel_id, session)
async def cancel_game(self, ctx: commands.context): """ Cancels and voids your ongoing game. Require validation from other players in the game. """ player = await self.bot.get_player(ctx) session = get_session() game, participant = player.get_last_game() # If the game is already done and scored, we don’t offer cancellation anymore. if game.winner: no_cancel_notice = f"You don’t seem to currently be in a game." logging.info(no_cancel_notice) await ctx.send(no_cancel_notice) return discord_ids_list = [ p.player.discord_id for p in game.participants.values() ] cancelling_game_message = await ctx.send( f"Trying to cancel the game including {self.get_tags(discord_ids_list)}.\n" "If you want to cancel the game, have at least 6 players press ✅.\n" "If you did not mean to cancel the game, press ❎.", delete_after=self.bot.validation_duration, ) cancel, cancelling_players = await self.checkmark_validation( cancelling_game_message, discord_ids_list, 6) if not cancel: # If there’s no validation, we just inform players nothing happened and leave no_cancellation_message = f"Cancellation canceled." logging.info(no_cancellation_message) await ctx.send(no_cancellation_message, delete_after=30) return # If we get here, 6+ players accepted to cancel the game session.delete(game) session.commit() cancel_notice = f"Game {game.id} cancelled." logging.info(cancel_notice) await ctx.send(cancel_notice)
async def view_games(self, ctx: commands.context): """ Shows the ongoing inhouse games. """ session = get_session() games_without_results = session.query(Game).filter(Game.winner == None).all() if not games_without_results: await ctx.send('No active games found', delete_after=self.bot.short_notice_duration) return embed = Embed(title='Ongoing games', colour=discord.colour.Colour.dark_blue()) for game in games_without_results: embed.add_field(name=f'Game {game.id}', value=f'```{game}```') await ctx.send(embed=embed)
async def start_game(self, ctx, players, mismatch=False): """ Attempts to start the given game by pinging players and waiting for their reactions. """ # Start by removing all players from the channel queue before starting the game players_queues = {} for player in players.values(): players_queues[ player.discord_id] = await self.remove_player_from_queue(player ) game_session = get_session() game = Game(players) game_session.add(game) game_session.commit() ready, ready_players = await self.ready_check(ctx, players, mismatch, game) if not ready: # If the ready check fails, we delete the game and let !queue handle restarting matchmaking. for player_id in ready_players: for queue, role in players_queues[player_id]: self.add_player_to_queue( await self.bot.get_player(None, player_id), role, queue) await ctx.send( f'The game has been cancelled.\n' f'Players who pressed ✅ have been put back in queue.\n' f'{self.get_tags([discord_id for discord_id in players_queues if discord_id not in ready_players])}' f' have been removed from queue.') game_session.delete(game) game_session.commit() # We return and restart matchmaking with the new queue await self.send_queue(ctx) return await self.matchmaking_process(ctx) validation_message = f'Game {game.id} has been accepted and can start!' logging.info(validation_message) await ctx.send(validation_message)
async def mmr_history(self, ctx: commands.Context, date_start=None): """ Displays a graph of your MMR history over the past month. """ if not date_start: date_start = dateparser.parse('one month ago') else: date_start = dateparser.parse(date_start) player = await self.bot.get_player(ctx) session = get_session() # TODO Use the player_rating.game_participant_objects field? participants = session.query(Game, GameParticipant)\ .join(GameParticipant)\ .filter(GameParticipant.player_id == player.discord_id)\ .filter(Game.date > date_start) mmr_history = defaultdict(lambda: {'dates': [], 'mmr': []}) for game, participant in participants: mmr_history[participant.role]['dates'].append(game.date) mmr_history[participant.role]['mmr'].append(participant.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(player.ratings[role].mmr) 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 {player.name}') mplcyberpunk.add_glow_effects() 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()
async def ranking(self, ctx: commands.Context, role="all"): """ Returns the top 20 players for the selected role in the server. """ if not ctx.guild: await ctx.send( "!ranking can only be called inside a Discord server.", delete_after=30) return if role == "all": clean_role = role else: clean_role, score = process.extractOne(role, roles_list) if score < 80: await ctx.send(self.bot.role_not_understood, delete_after=30) return session = get_session() guild_player_ids = [m.id for m in ctx.guild.members] role_ranking = (session.query(PlayerRating).join(Player).order_by( -PlayerRating.mmr).filter(PlayerRating.games > 0).filter( Player.discord_id.in_(guild_player_ids))) if clean_role != "all": role_ranking = role_ranking.filter(PlayerRating.role == clean_role) table = [["Rank", "Name", "MMR", "Games"] + ["Role" if clean_role == "all" else None]] for rank, rating in enumerate(role_ranking.limit(20)): table.append([ inflect_engine.ordinal(rank + 1), rating.player.name, f"{rating.mmr:.1f}", rating.get_games_total(), ] + [rating.role if clean_role == "all" else None]) await ctx.send(f"Ranking for {clean_role} is:\n" f'```{tabulate(table, headers="firstrow")}```')
async def view_team(self, ctx: commands.Context): """View your current team and team mates. """ player = await self.bot.get_player(ctx) if not player.team: await ctx.send( f"Your team has not been set yet. Please contact a server admin to get tagged." ) return teammates_session = get_session() teammates = teammates_session.query(Player).filter( Player.team == player.team) await ctx.send( f"You are currently part of {player.team}. Please contact a server admin for changes.\n" f'Currently in {player.team}: {", ".join([t.name for t in teammates])}' )
async def champion(self, ctx, champion_name, game_id=None): """ Informs the champion you used for the chosen game (or the last game by default) Example: !champion riven !champion riven 1 """ try: champion_id = lit.get_id(champion_name, object_type="champion") except lit.NoMatchingNameFound: await ctx.send( "Champion name was not understood properly.\n" "Use `!help champion` for more information.", delete_after=self.bot.warning_duration, ) return player = await self.bot.get_player(ctx) session = get_session() if game_id: game, participant = (session.query(Game, GameParticipant).join( GameParticipant).filter(Game.id == game_id).filter( GameParticipant.player_id == player.discord_id).order_by( Game.date.desc()).first()) else: game, participant = player.get_last_game() participant.champion_id = champion_id session.merge(participant) session.commit() log_message = ( f"Champion for game {game.id} set to {lit.get_name(participant.champion_id)} for {player.name}" ) logging.info(log_message) await ctx.send(log_message, delete_after=self.bot.short_notice_duration)
async def test_player(caplog): """ Tests the addition of the Bot user as a player itself. Also tests PlayerRating and makes sure the on_cascade flags are applied properly. """ caplog.set_level(logging.INFO) from inhouse_bot.common_utils import discord_token from inhouse_bot.sqlite.player_rating import PlayerRating from inhouse_bot.sqlite.sqlite_utils import get_session from inhouse_bot.sqlite.player import Player client = discord.Client() await client.login(discord_token) test_user = await client.fetch_user(707853630681907253) await client.close() session = get_session() player = Player(test_user) player = session.merge(player) session.commit() assert player == session.query(Player).filter( Player.discord_id == test_user.id).one() player_rating = PlayerRating(player, 'mid') session.add(player_rating) session.commit() assert player.ratings session.delete(player) session.commit() assert session.query(Player).filter( Player.discord_id == test_user.id).one_or_none() is None assert session.query(PlayerRating).filter( PlayerRating.player_id == test_user.id).one_or_none() is None
async def test_game(caplog): """ Tests the addition of a game. """ # TODO Make this test cleaner from inhouse_bot.inhouse_bot import InhouseBot from inhouse_bot.sqlite.sqlite_utils import get_session from inhouse_bot.sqlite.sqlite_utils import roles_list from inhouse_bot.sqlite.game import Game from inhouse_bot.sqlite.player import Player from inhouse_bot.sqlite.player_rating import PlayerRating session = get_session() # We create our bot object, a mock server, a mock channel, 10 mock members, and return our cog for testing bot = InhouseBot() dpytest.configure(bot, 1, 1, 10) players = {} for member_id in range(0, 10): role = roles_list[member_id % 5] player = Player(dpytest.get_config().members[member_id]) rating = PlayerRating(player, role) session.add(player) session.add(rating) session.commit() players["blue" if member_id % 2 else "red", role] = player game = Game(players) session.add(game) # Printing ahead of time try: print(game) except AttributeError: assert True session.commit() print(game)
def __init__(self, **options): super().__init__('!', help_command=IndexedHelpCommand(dm_help=True), **options) self.discord_token = discord_token self.players_session = get_session() # Local imports to not have circular imports with type hinting from inhouse_bot.cogs.queue_cog import QueueCog from inhouse_bot.cogs.stats_cog import StatsCog self.add_cog(QueueCog(self)) self.add_cog(StatsCog(self)) self.role_not_understood = 'Role name was not properly understood. ' \ 'Working values are top, jungle, mid, bot, and support.' self.short_notice_duration = 10 self.validation_duration = 60 self.warning_duration = 30
async def champion(self, ctx, champion_name, game_id=None): """ Informs the champion you used for the chosen game (or the last game by default) Example: !champion riven !champion riven 1 """ champion_id, ratio = self.bot.lit.get_id(champion_name, input_type='champion', return_ratio=True) if ratio < 75: await ctx.send('Champion name was not understood properly.\n' 'Use `!help champion` for more information.', delete_after=self.bot.warning_duration) return player = await self.bot.get_player(ctx) session = get_session() if game_id: game, participant = session.query(Game, GameParticipant).join(GameParticipant) \ .filter(Game.id == game_id) \ .filter(GameParticipant.player_id == player.discord_id) \ .order_by(Game.date.desc()) \ .first() else: game, participant = player.get_last_game() participant.champion_id = champion_id session.merge(participant) session.commit() log_message = f'Champion for game {game.id} set to {self.bot.lit.get_name(participant.champion_id)} for {player.name}' logging.info(log_message) await ctx.send(log_message, delete_after=self.bot.short_notice_duration)
def score_game(player_ids: Dict[Tuple[str, str], int], winner): """ players: ("red", "top") -> discord_id """ session = get_session() player_objects = session.query(Player).filter( Player.discord_id.in_(player_ids.values())).all() players = {} for k, v in player_ids.items(): print(v) players[k] = next(p for p in player_objects if p.discord_id == v) # Changed for debugging # players = {k: next(p for p in player_objects if p.discord_id == v) for k, v in player_ids.items()} game = Game(players) session.add(game) # Deleting test # session.query(QueuePlayer).filter(QueuePlayer.player_id.in_(player_ids.values())).delete( # synchronize_session=False # ) # session.commit() # session.close() # # return # Necessary to get IDs session.flush() game.winner = winner game.update_trueskill() session.close()
async def checkmark_validation( self, message: discord.Message, validating_members: list, validation_threshold: int, timeout=120.0, queue=False, ) -> Tuple[Optional[bool], set]: """ Implements a checkmark validation on the chosen message. Returns True if validation_threshold members in validating_members pressed '✅' before the timeout. """ await message.add_reaction("✅") await message.add_reaction("❎") def check(received_reaction: discord.Reaction, sending_user: discord.User): # This check is simply used to see if a player in the game responded to the message. # Queue logic is handled below return (received_reaction.message.id == message.id and sending_user.id in validating_members and str(received_reaction.emoji) in ["✅", "❎"]) members_who_validated = set() try: # TODO Remove that while True for something smarter while True: reaction, user = await self.bot.wait_for("reaction_add", timeout=timeout, check=check) if str(reaction.emoji) == "✅": if queue: session = get_session() session.query(QueuePlayer).filter( QueuePlayer.player_id == user.id).filter( QueuePlayer.channel_id == message.channel.id).update( {"ready_check": True}, synchronize_session=False) session.commit() session.close() members_who_validated.add(user.id) if members_who_validated.__len__() >= validation_threshold: return True, members_who_validated elif str(reaction.emoji) == "❎": if queue: session = get_session() session.query(QueuePlayer).filter( QueuePlayer.player_id == user.id).filter( QueuePlayer.channel_id == message.channel.id ).delete(synchronize_session=False) session.commit() session.close() return False, members_who_validated # We get there if no player accepted in the last x minutes except asyncio.TimeoutError: return None, members_who_validated
def find_best_game( channel_id) -> Tuple[Dict[Tuple[str, str], Player], int]: """ Looks at the queue in the channel and returns the best match-made game (as a {team, role} -> Player dict). """ # Getting players in queue session = get_session() players_in_queue = (session.query(QueuePlayer).options( joinedload(QueuePlayer.player)).filter( QueuePlayer.channel_id == channel_id).filter( QueuePlayer.ready_check == None).all()) queue = {} for role in roles_list: queue[role] = [ p.player for p in players_in_queue if p.role == role ] # Do not do anything if there’s not at least 2 players in queue per role for role in queue: if len(queue[role]) < 2: logging.debug("Not enough players to start matchmaking") return {}, -1 logging.info("Starting matchmaking process") # Simply testing all permutations because it should be pretty lightweight # TODO Spot mirrored team compositions (full blue/red -> red/blue) to not calculate them twice role_permutations = [] for role in roles_list: role_permutations.append( [p for p in itertools.permutations(queue[role], 2)]) # Very simple maximum search best_score = -1 best_players = {} for team_composition in itertools.product(*role_permutations): # players: [team, role] -> Player players = { ("red" if tuple_idx else "blue", roles_list[role_idx]): players_tuple[tuple_idx] for role_idx, players_tuple in enumerate(team_composition) for tuple_idx in (0, 1) } # We check to make sure all 10 players are different if set(players.values()).__len__() != 10: continue # Defining the score as -|0.5-expected_blue_winrate| to be side-agnostic. score = -abs(0.5 - trueskill_blue_side_winrate(players)) if score > best_score: best_players = players best_score = score # If the game is seen as being below 51% winrate for one side, we simply stop there if best_score > -0.01: break session.close() logging.info( "The best match found had a score of {}".format(best_score)) return best_players, best_score
async def start_game(self, ctx, players: Dict[Tuple[str, str], Player], mismatch=False): """ Attempts to start the given game by pinging players and waiting for their reactions. """ player_ids = [p.discord_id for p in players.values()] session = get_session() session.query(QueuePlayer).filter( QueuePlayer.player_id.in_(player_ids)).update( {"ready_check": False}, synchronize_session=False) session.commit() session.close() await self.send_queue(ctx) ready, ready_players = await self.ready_check(ctx, players, mismatch) if ready is True: game = Game(players) session = get_session() # We create the game session.add(game) # We drop the players from queue session.query(QueuePlayer).filter( QueuePlayer.player_id.in_(player_ids)).delete( synchronize_session=False) session.commit() session.close() validation_message = ( f"Game has been accepted and can start!\n" f"Score it with `!won` or `!lost` after it has been played.") logging.info(validation_message) await ctx.send(validation_message) await self.send_queue(ctx) # The queue failed else: if ready is False: # Someone refused session = get_session() message = ( f"The game has been cancelled.\n" f"The player has been removed from queue and others have been put back in queue." ) else: # It timed out, so we quick the players who did not accept session = get_session() session.query(QueuePlayer).filter( QueuePlayer.ready_check == False).delete() message = f"The game has timed out.\n" f"Players who pressed ✅ have been put back in queue." # We get there only if ready is False or None session.query(QueuePlayer).filter( QueuePlayer.player_id.in_(player_ids)).update( {"ready_check": None}, synchronize_session=False) session.commit() session.close() await ctx.send(message) # We return and restart matchmaking with the new queue await self.send_queue(ctx) return await self.matchmaking_process(ctx)