diff --git a/.gitignore b/.gitignore index 8c60208..aaa5a92 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /.venv* /config.json /credentials.json +.idea/* +/example_configs/test/* diff --git a/cogs/SquadQueue.py b/cogs/SquadQueue.py index 7abd1e9..ba1f41f 100644 --- a/cogs/SquadQueue.py +++ b/cogs/SquadQueue.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import aiohttp import discord from discord.ext import commands, tasks from discord import app_commands @@ -5,27 +8,64 @@ from datetime import datetime, timezone, timedelta import time import json -from mmr import mk8dx_150cc_mmr, get_mmr_from_discord_id, mk8dx_150cc_fc -from mogi_objects import Mogi, Team, Player, Room, VoteView, JoinView, get_tier + +import common +from common import divide_chunks +from mmr import get_mmr, get_mmr_from_discord_id +from mogi_objects import Mogi, Team, Player, Room, VoteView, JoinView, get_tier, get_tier_mk8dx import asyncio +from collections import defaultdict +from typing import Dict, List, Tuple +import traceback +import os +import dill + + +headers = {'Content-type': 'application/json'} # Scheduled_Event = collections.namedtuple('Scheduled_Event', 'size time started mogi_channel') +cooldowns = defaultdict(int) +MMR_THRESHOLD_PKL = "mmr_threshold.pkl" + + +def is_restricted(user: discord.User | discord.Member, config: dict) -> bool: + muted_role_id = config.get("muted_role_id") + restricted_role_id = config.get("restricted_role_id") + return (muted_role_id is not None and user.get_role(muted_role_id)) \ + or (restricted_role_id is not None and user.get_role(restricted_role_id)) + + +def basic_threshold_players_allowed(players: List[Player], threshold: int) -> bool: + """Returns True if the highest player's rating minus the lowest player's rating is below the given threshold""" + sorted_players = sorted(players, key=lambda p: p.mmr) + return (sorted_players[-1].mmr - sorted_players[0].mmr) <= threshold + + +def mkw_players_allowed(players: List[Player], threshold: int) -> bool: + """Returns true if the given list of players would be allowed to play together""" + return basic_threshold_players_allowed(players, threshold) + + +def mk8dx_players_allowed(players: List[Player], threshold: int) -> bool: + """Returns true if the given list of players would be allowed to play together""" + return basic_threshold_players_allowed(players, threshold) + class SquadQueue(commands.Cog): def __init__(self, bot): - self.bot = bot + self.bot: commands.Bot = bot + + self.is_production = bot.config["is_production"] + + self.next_event: Mogi = None - # keys are discord.Guild objects, values are list of Mogi instances - self.scheduled_events = {} - # keys are discord.TextChannel objects, values are instances of Mogi - self.ongoing_events = {} + self.ongoing_event: Mogi = None - self.old_events = {} + self.old_events: List[Mogi] = [] self.sq_times = [] - self._que_scheduler = self.que_scheduler.start() self._scheduler_task = self.sqscheduler.start() self._msgqueue_task = self.send_queued_messages.start() self._list_task = self.list_task.start() @@ -35,36 +75,73 @@ def __init__(self, bot): self.list_messages = [] - self.QUEUE_TIME_BLOCKER = datetime.now(timezone.utc) + self.LAUNCH_NEW_EVENTS = True - self.GUILD = None + self.GUILD: discord.Guild = None self.MOGI_CHANNEL = None self.SUB_CHANNEL = None + self.LIST_CHANNEL = None + self.HISTORY_CHANNEL = None + self.GENERAL_CHANNEL = None + self.LOCK = asyncio.Lock() self.URL = bot.config["url"] + self.PLACEMENT_PLAYER_MMR = self.bot.config["PLACEMENT_PLAYER_MMR"] + + self.ROOM_JOIN_PENALTY_TIME = self.bot.config["ROOM_JOIN_PENALTY_TIME"] + self.MOGI_LIFETIME = bot.config["MOGI_LIFETIME"] + self.SUB_RANGE_MMR_ALLOWANCE = bot.config["SUB_RANGE_MMR_ALLOWANCE"] + self.SUB_MESSAGE_LIFETIME_SECONDS = bot.config["SUB_MESSAGE_LIFETIME_SECONDS"] - # number of minutes before scheduled time that queue should open + self.room_mmr_threshold = bot.config["ROOM_MMR_THRESHOLD"] + if os.path.isfile(MMR_THRESHOLD_PKL): + try: + with open(MMR_THRESHOLD_PKL, 'rb') as f: + self.room_mmr_threshold = dill.load(f) + except: + print(traceback.format_exc()) + + self.TRACK_TYPE = bot.config["track_type"] + + self.TIER_INFO = [] + + # Time between each event queue opening self.QUEUE_OPEN_TIME = timedelta(minutes=bot.config["QUEUE_OPEN_TIME"]) # number of minutes after QUEUE_OPEN_TIME that teams can join the mogi self.JOINING_TIME = timedelta(minutes=bot.config["JOINING_TIME"]) + self.DISPLAY_OFFSET_MINUTES = timedelta( + minutes=bot.config["DISPLAY_OFFSET_MINUTES"]) + # number of minutes after JOINING_TIME for any potential extra teams to join self.EXTENSION_TIME = timedelta(minutes=bot.config["EXTENSION_TIME"]) + assert (self.QUEUE_OPEN_TIME >= self.JOINING_TIME + self.EXTENSION_TIME) + + # This is the first event of the day's time. However, this isn't the first event the bot will run. This is literally the time of the first event in a daily schedule. + self.FIRST_EVENT_TIME = datetime.combine(datetime.utcnow().date(), datetime.min.time(), + tzinfo=timezone.utc) + timedelta( + minutes=bot.config["FIRST_EVENT_TIME"]) + with open('./timezones.json', 'r') as cjson: self.timezones = json.load(cjson) + # The following dictionary will be populated using the config's staff_helper_roles list. Players can + # call these roles into their room/thread using the /staff role command + # These will be refreshed every 24 hours to ensure that the correct name displays for the options + self.helper_staff_roles: Dict[str, discord.Role] = {} + @commands.Cog.listener() async def on_ready(self): self.GUILD = self.bot.get_guild(self.bot.config["guild_id"]) @@ -72,22 +149,45 @@ async def on_ready(self): self.bot.config["queue_join_channel"]) self.SUB_CHANNEL = self.bot.get_channel( self.bot.config["queue_sub_channel"]) + self.LIST_CHANNEL = self.bot.get_channel( + self.bot.config["queue_list_channel"]) self.HISTORY_CHANNEL = self.bot.get_channel( self.bot.config["queue_history_channel"]) + self.GENERAL_CHANNEL = self.bot.get_channel( + self.bot.config["queue_general_channel"]) + if common.SERVER is common.Server.MKW: + await self.get_ladder_info() + try: + await self.LIST_CHANNEL.purge() + except: + print("Purging List channel failed", flush=True) + print(traceback.format_exc()) + try: + await self.SUB_CHANNEL.purge() + except: + print("Purging Sub channel failed", flush=True) + print(traceback.format_exc()) print(f"Server - {self.GUILD}", flush=True) print(f"Join Channel - {self.MOGI_CHANNEL}", flush=True) print(f"Sub Channel - {self.SUB_CHANNEL}", flush=True) + print(f"List Channel - {self.LIST_CHANNEL}", flush=True) print(f"History Channel - {self.HISTORY_CHANNEL}", flush=True) + print(f"General Channel - {self.GENERAL_CHANNEL}", flush=True) print("Ready!", flush=True) + self.refresh_helper_roles.start() + async def lockdown(self, channel: discord.TextChannel): # everyone_perms = channel.permissions_for(channel.guild.default_role) # if not everyone_perms.send_messages: # return - overwrite = channel.overwrites_for(channel.guild.default_role) - overwrite.send_messages = False - await channel.set_permissions(channel.guild.default_role, overwrite=overwrite) - await channel.send("Locked down " + channel.mention) + try: + overwrite = channel.overwrites_for(channel.guild.default_role) + overwrite.send_messages = False + await channel.set_permissions(channel.guild.default_role, overwrite=overwrite) + await channel.send("Locked down " + channel.mention) + except Exception as e: + print(traceback.format_exc()) async def unlockdown(self, channel: discord.TextChannel): # everyone_perms = channel.permissions_for(channel.guild.default_role) @@ -129,44 +229,108 @@ async def send_queued_messages(self): for i in range(len(sentmsgs)-1, -1, -1): await channel.send(sentmsgs[i]) except Exception as e: - print(e) + print(traceback.format_exc()) - def get_mogi(self, ctx): - if ctx.channel in self.ongoing_events.keys(): - return self.ongoing_events[ctx.channel] - return None + def get_mogi(self, ctx: discord.Interaction | discord.ext.commands.context.Context) -> Mogi | None: + if self.ongoing_event is None: + return None + if self.ongoing_event.mogi_channel.id == ctx.channel.id: + return self.ongoing_event - async def is_started(self, ctx, mogi): + def is_staff(self, member: discord.Member): + return any(member.get_role(staff_role) for staff_role in self.bot.config["admin_roles"]) + + async def is_started(self, ctx, mogi: Mogi): if not mogi.started: await ctx.send("Mogi has not been started yet... type !start") - return False - return True + return mogi.started - async def is_gathering(self, ctx, mogi): + async def is_gathering(self, ctx, mogi: Mogi): if not mogi.gathering: await ctx.send("Mogi is closed; players cannot join or drop from the event") - return False - return True + return mogi.gathering + + @app_commands.command(name="extend") + @app_commands.guild_only() + async def extend(self, interaction: discord.Interaction, minutes: int): + """Extend the queue + + Parameters + ----------- + minutes: int + The number of minutes to add to the extension time. Can be negative. + + Staff use only. + """ + mogi = self.get_mogi(interaction) + if mogi is None or not mogi.started or not mogi.gathering: + await interaction.followup.send("Queue has not started yet.") + return + mogi.additional_extension += timedelta(minutes=minutes) + await interaction.response.send_message(f"Extended queue by an additional {minutes} minute(s).") + + @app_commands.command(name="ch") + @app_commands.guild_only() + async def can_host(self, interaction: discord.Interaction): + """Join a mogi as a host""" + await self.join_queue(interaction, host=True) @app_commands.command(name="c") @app_commands.guild_only() async def can(self, interaction: discord.Interaction): """Join a mogi""" + await self.join_queue(interaction) + + async def join_queue(self, interaction: discord.Interaction, host=False): await interaction.response.defer() async with self.LOCK: member = interaction.user + url = self.URL + if not self.is_production: + if common.SERVER is common.Server.MK8DX: + # is actually a user and not a member + member = await self.bot.fetch_user(318637887597969419) + url = "https://www.mk8dx-lounge.com" + elif common.SERVER is common.Server.MKW: + member = await self.bot.fetch_user(82862780591378432) + url = "https://www.mkwlounge.gg" mogi = self.get_mogi(interaction) if mogi is None or not mogi.started or not mogi.gathering: await interaction.followup.send("Queue has not started yet.") return player_team = mogi.check_player(member) - - if player_team is not None: - await interaction.followup.send(f"{interaction.user.mention} is already signed up.") + player = None if player_team is None else player_team.get_player( + member) + + if player is not None: + # The player is already signed up, but they might be changing to a host or non-host. Begin checks: + # The player was queued as host, and they queued again as a host + if player.host and host: + await interaction.followup.send(f"{interaction.user.mention} is already signed up as a host.") + # The player was queued as host, and they queued again as a non-host + elif player.host and not host: + await interaction.followup.send(f"{interaction.user.mention} has changed to a non-host.") + # The player was not queued as host, but they are changing to a host + elif not player.host and host: + await interaction.followup.send(f"{interaction.user.mention} has changed to a host.") + # The player was not queued as host and did not change to a host + elif not player.host and not host: + await interaction.followup.send(f"{interaction.user.mention} is already signed up.") + + player.host = host return - players = await mk8dx_150cc_mmr(self.URL, [member]) + msg = "" + placement_role_id = self.bot.config["placement_role_id"] + if self.is_production and member.get_role(placement_role_id): + starting_player_mmr = self.PLACEMENT_PLAYER_MMR + players = [ + Player(member, member.display_name, starting_player_mmr)] + msg += f"{players[0].lounge_name} is assumed to be a new player and will be playing this mogi with a starting MMR of {starting_player_mmr}. " + msg += "If you believe this is a mistake, please contact a staff member for help.\n" + else: + players = await get_mmr(url, [member], self.TRACK_TYPE) if len(players) == 0 or players[0] is None: msg = f"{interaction.user.mention} fetch for MMR has failed and joining the queue was unsuccessful. " @@ -174,18 +338,12 @@ async def can(self, interaction: discord.Interaction): await interaction.followup.send(msg) return - msg = "" - if players[0].mmr is None: - starting_player_mmr = 1500 - players[0].mmr = starting_player_mmr - msg += f"{players[0].lounge_name} is assumed to be a new player and will be playing this mogi with a starting MMR of {starting_player_mmr}. " - msg += "If you believe this is a mistake, please contact a staff member for help.\n" - players[0].confirmed = True + players[0].host = host squad = Team(players) mogi.teams.append(squad) - - msg += f"{players[0].lounge_name} joined queue for mogi {discord.utils.format_dt(mogi.start_time, style='R')}, `[{mogi.count_registered()} players]`" + host_str = " as a host " if host else " " + msg += f"{players[0].lounge_name} joined queue{host_str}closing at {discord.utils.format_dt(mogi.start_time)}, `[{mogi.count_registered()} players]`" await interaction.followup.send(msg) await self.check_room_channels(mogi) @@ -205,7 +363,7 @@ async def drop(self, interaction: discord.Interaction): member = interaction.user squad = mogi.check_player(member) if squad is None: - await interaction.followup.send(f"{member.display_name} is not currently in this event; type `/c` to join") + await interaction.followup.send(f"{member.display_name} is not currently in this event; type `/c` or `/ch` to join") return mogi.teams.remove(squad) msg = "Removed " @@ -214,133 +372,132 @@ async def drop(self, interaction: discord.Interaction): msg += f", `[{mogi.count_registered()} players]`" await interaction.followup.send(msg) + await self.check_num_teams(mogi) @app_commands.command(name="sub") @app_commands.guild_only() + # @commands.cooldown(rate=1, per=120, type=commands.BucketType.user) async def sub(self, interaction: discord.Interaction): """Sends out a request for a sub in the sub channel. Only works in thread channels for SQ rooms.""" + current_time = time.time() + lastCommandTime = cooldowns.get(interaction.user.id) + print(f"{interaction.user.name} requests a sub, previous sub command time: {lastCommandTime}", flush=True) + if lastCommandTime == None: + lastCommandTime = 0 + + # Cooldown timer in seconds + if (current_time - lastCommandTime) < 120 and not self.is_staff(interaction.user): + await interaction.response.send_message( + f"You are still on cooldown. Please wait for {int(2 * 60 - (current_time - lastCommandTime))} more seconds to use this command again.", + ephemeral=True) + return + is_room_thread = False room = None - for mogi in self.ongoing_events.values(): + bottom_room_num = 1 + mogi = self.ongoing_event + if mogi is not None: if mogi.is_room_thread(interaction.channel_id): room = mogi.get_room_from_thread(interaction.channel_id) + bottom_room_num = len(mogi.rooms) is_room_thread = True - break - for mogi in self.old_events.values(): - if mogi.is_room_thread(interaction.channel.id): - room = mogi.get_room_from_thread(interaction.channel.id) + for old_mogi in self.old_events: + if old_mogi.is_room_thread(interaction.channel.id): + room = old_mogi.get_room_from_thread(interaction.channel.id) + bottom_room_num = len(old_mogi.rooms) is_room_thread = True break if not is_room_thread: - await interaction.response.send_message(f"More than {self.MOGI_LIFETIME} minutes have passed since mogi start, the Mogi Object has been deleted.", ephemeral=True) + await interaction.response.send_message( + f"More than {self.MOGI_LIFETIME} minutes have passed since mogi start, the Mogi Object has been deleted.", + ephemeral=True) return - msg = "<@&682445864400453739> - " - if room.room_num == 1: - msg += f"Room {room.room_num} is looking for a sub with mmr >{room.mmr_low - 500}\n" + frequently_tagged_role_id = self.bot.config["frequently_tagged_role_id"] + msg = f"<@&{frequently_tagged_role_id}> - " + if bottom_room_num == 1: + msg += f"Room 1 is looking for a sub with any mmr\n" + elif room.room_num == 1: + msg += f"Room 1 is looking for a sub with mmr >{room.mmr_low - self.SUB_RANGE_MMR_ALLOWANCE}\n" + elif room.room_num == bottom_room_num: + msg += f"Room {room.room_num} is looking for a sub with mmr <{room.mmr_high + self.SUB_RANGE_MMR_ALLOWANCE}\n" else: - low = 0 if room.mmr_low < 500 else room.mmr_low - 500 - msg += f"Room {room.room_num} is looking for a sub with range {low}-{room.mmr_high + 500}\n" + msg += f"Room {room.room_num} is looking for a sub with range {room.mmr_low - self.SUB_RANGE_MMR_ALLOWANCE}-{room.mmr_high + self.SUB_RANGE_MMR_ALLOWANCE}\n" message_delete_date = datetime.now( timezone.utc) + timedelta(seconds=self.SUB_MESSAGE_LIFETIME_SECONDS) msg += f"Message will auto-delete in {discord.utils.format_dt(message_delete_date, style='R')}" await self.SUB_CHANNEL.send(msg, delete_after=self.SUB_MESSAGE_LIFETIME_SECONDS) - view = JoinView(room, get_mmr_from_discord_id) + view = JoinView(room, + lambda discord_id: get_mmr_from_discord_id(self.URL, + discord_id, self.TRACK_TYPE), + self.SUB_RANGE_MMR_ALLOWANCE, + bottom_room_num, + lambda user: is_restricted(user, self.bot.config)) await self.SUB_CHANNEL.send(view=view, delete_after=self.SUB_MESSAGE_LIFETIME_SECONDS) + cooldowns[interaction.user.id] = current_time # Updates cooldown await interaction.response.send_message("Sent out request for sub.") - @app_commands.command(name="l") - @app_commands.checks.cooldown(1, 120, key=lambda i: (i.channel.id)) - @app_commands.guild_only() - async def list(self, interaction: discord.Interaction): - """Display the list of confirmed players for a mogi""" - mogi = self.get_mogi(interaction) - if mogi is None: - await interaction.response.send_message("Queue has not started yet.") - return - if not await self.is_started(interaction, mogi): - return - mogi_list = mogi.confirmed_list() - if len(mogi_list) == 0: - await interaction.response.send_message(f"There are no players in the queue - type `/c` to join") - return - - sorted_mogi_list = sorted(mogi_list, reverse=True) - msg = "Current Mogi List:\n" - for i in range(len(sorted_mogi_list)): - msg += f"{i+1}) " - msg += ", ".join([p.lounge_name for p in sorted_mogi_list[i].players]) - msg += f" ({sorted_mogi_list[i].avg_mmr:.1f} MMR)\n" - if (len(sorted_mogi_list) % (12/mogi.size) != 0): - num_next = int(len(sorted_mogi_list) % (12/mogi.size)) - teams_per_room = int(12/mogi.size) - num_rooms = int(len(sorted_mogi_list) / (12/mogi.size))+1 - msg += f"[{num_next}/{teams_per_room}] players for {num_rooms} room(s)" - message = msg.split("\n") - bulk_msg = "" - for i in range(len(message)): - if len(bulk_msg + message[i] + "\n") > 2000: - await interaction.channel.send(bulk_msg) if interaction.response.is_done() else await interaction.response.send_message(bulk_msg) - bulk_msg = "" - bulk_msg += message[i] + "\n" - if len(bulk_msg) > 0: - await interaction.channel.send(bulk_msg) if interaction.response.is_done() else await interaction.response.send_message(bulk_msg) - - @list.error # Tell the user when they've got a cooldown - async def on_list_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError): - if isinstance(error, app_commands.CommandOnCooldown): - await interaction.response.send_message("Wait before using `/l` command", ephemeral=True) - - @tasks.loop(minutes=1) + @tasks.loop(seconds=30) async def list_task(self): """Continually display the list of confirmed players for a mogi in the history channel""" - if len(self.ongoing_events) > 0: - for mogi in self.ongoing_events.values(): - if not mogi.gathering: - await self.delete_list_messages(0) - return - - mogi_list = mogi.confirmed_list() - - sorted_mogi_list = sorted(mogi_list, reverse=True) - msg = "Current Mogi List:\n" - for i in range(len(sorted_mogi_list)): - msg += f"{i+1}) " - msg += ", ".join([p.lounge_name for p in sorted_mogi_list[i].players]) - msg += f" ({sorted_mogi_list[i].players[0].mmr} MMR)\n" - if ((i + 1) % 12 == 0): - msg += "\n" - if (len(sorted_mogi_list) % (12/mogi.size) != 0): - num_next = int(len(sorted_mogi_list) % (12/mogi.size)) - teams_per_room = int(12/mogi.size) - num_rooms = int(len(sorted_mogi_list) / (12/mogi.size))+1 - msg += f"[{num_next}/{teams_per_room}] players for {num_rooms} room(s)" - message = msg.split("\n") - - new_messages = [] - bulk_msg = "" - for i in range(len(message)): - if len(bulk_msg + message[i] + "\n") > 2000: - new_messages.append(bulk_msg) - bulk_msg = "" - bulk_msg += message[i] + "\n" - if len(bulk_msg) > 0: + mogi = self.ongoing_event + if mogi is not None: + if not mogi.gathering: + await self.delete_list_messages(0) + return + all_confirmed_players = mogi.players_on_confirmed_teams() + first_late_player_index = ( + mogi.num_players // mogi.players_per_room) * mogi.players_per_room + on_time_players = sorted( + all_confirmed_players[:first_late_player_index], reverse=True) + late_players = all_confirmed_players[first_late_player_index:] + + msg = f"**Queue closing: {discord.utils.format_dt(mogi.start_time)}**\n\n" + msg += "**Current Mogi List:**\n" + if common.SERVER is common.Server.MKW: + for i, player in enumerate(on_time_players, 1): + msg += f"{i}) {player.lounge_name} ({player.mmr} MMR)\n" + if i % mogi.players_per_room == 0: + msg += "ㅤ\n" + if len(on_time_players) == 0: # Text looks better this way + msg += "\n" + msg += "**Late Players:**\n" + for i, player in enumerate(late_players, 1): + msg += f"{i}) {player.lounge_name} ({player.mmr} MMR)\n" + elif common.SERVER is common.Server.MK8DX: + all_confirmed_players.sort(reverse=True) + for i, player in enumerate(all_confirmed_players, 1): + late_str = " (late)" if player in late_players else "" + msg += f"{i}) {player.lounge_name} ({player.mmr} MMR){late_str}\n" + if i % mogi.players_per_room == 0: + msg += "ㅤ\n" + msg += f"\n**Last Updated:** {discord.utils.format_dt(datetime.now(timezone.utc), style='R')}" + message = msg.split("\n") + + new_messages = [] + bulk_msg = "" + for i in range(len(message)): + if len(bulk_msg + message[i] + "\n") > 2000: new_messages.append(bulk_msg) + bulk_msg = "" + bulk_msg += message[i] + "\n" + if len(bulk_msg) > 0 and bulk_msg != "\n": + new_messages.append(bulk_msg) - await self.delete_list_messages(len(new_messages)) + await self.delete_list_messages(len(new_messages)) - try: - for i, message in enumerate(new_messages): - if i < len(self.list_messages): - old_message = self.list_messages[i] - await old_message.edit(content=message) - else: - new_message = await self.HISTORY_CHANNEL.send(message) - self.list_messages.append(new_message) - except: - await self.delete_list_messages(0) - for i, message in enumerate(new_messages): - new_message = await self.HISTORY_CHANNEL.send(message) + try: + for i, message in enumerate(new_messages): + if i < len(self.list_messages): + old_message = self.list_messages[i] + await old_message.edit(content=message) + else: + new_message = await self.LIST_CHANNEL.send(message) self.list_messages.append(new_message) + except: + await self.delete_list_messages(0) + for i, message in enumerate(new_messages): + new_message = await self.LIST_CHANNEL.send(message) + self.list_messages.append(new_message) else: await self.delete_list_messages(0) @@ -349,59 +506,64 @@ async def delete_list_messages(self, new_list_size: int): messages_to_delete = [] while len(self.list_messages) > new_list_size: messages_to_delete.append(self.list_messages.pop()) - if self.HISTORY_CHANNEL and len(messages_to_delete) > 0: - await self.HISTORY_CHANNEL.delete_messages(messages_to_delete) + if self.LIST_CHANNEL and len(messages_to_delete) > 0: + await self.LIST_CHANNEL.delete_messages(messages_to_delete) except Exception as e: - print(e, flush=True) + print(traceback.format_exc()) @commands.Cog.listener() async def on_message(self, message: discord.Message): if message.author.bot or not (message.content.isdecimal() and 12 <= int(message.content) <= 180): return - mogi = discord.utils.find(lambda mogi: mogi.is_room_thread( - message.channel.id), self.ongoing_events.values()) - if not mogi: - mogi = discord.utils.find(lambda mogi: mogi.is_room_thread( - message.channel.id), self.old_events.values()) - if not mogi: - return + mogi = None + if self.ongoing_event is not None: + mogi = self.ongoing_event if self.ongoing_event.is_room_thread( + message.channel.id) else None + if mogi is None: + mogi = discord.utils.find(lambda m: m.is_room_thread( + message.channel.id), self.old_events) + if mogi is None: + return room = discord.utils.find( - lambda room: room.thread.id == message.channel.id, mogi.rooms) - if not room or not room.teams: + lambda r: r.thread.id == message.channel.id, mogi.rooms) + if room is None or not room.teams: return team = discord.utils.find( - lambda team: team.has_player(message.author), room.teams) - if not team: + lambda t: t.has_player(message.author), room.teams) + if team is None: return player = discord.utils.find( - lambda player: player.member.id == message.author.id, team.players) - if player: + lambda p: p.member.id == message.author.id, team.players) + if player is not None: player.score = int(message.content) @app_commands.command(name="scoreboard") @app_commands.guild_only() async def scoreboard(self, interaction: discord.Interaction): """Displays the scoreboard of the room. Only works in thread channels for SQ rooms.""" + if common.SERVER is not common.Server.MK8DX: + await interaction.response.send_message(f"Command is only usable for MK8DX.", ephemeral=True) + return + + if not isinstance(interaction.channel, discord.Thread): + await interaction.response.send_message(f"Cannot use this command here.", ephemeral=True) + return mogi = discord.utils.find(lambda mogi: mogi.is_room_thread( - interaction.channel_id), self.ongoing_events.values()) + interaction.channel_id), self.old_events) if not mogi: - mogi = discord.utils.find(lambda mogi: mogi.is_room_thread( - interaction.channel_id), self.old_events.values()) - if not mogi: - await interaction.response.send_message(f"The Mogi object cannot be found.", ephemeral=True) - return + await interaction.response.send_message(f"The Mogi object cannot be found.", ephemeral=True) + return room = discord.utils.find( lambda room: room.thread.id == interaction.channel_id, mogi.rooms) - if not room: await interaction.response.send_message(f"The Thread object cannot be found.", ephemeral=True) return format = round(12/len(room.teams)) - msg = f"!submit {format} {get_tier(room.mmr_average - 500)}\n" + msg = f"!submit {format} {get_tier_mk8dx(room.mmr_average - 500)}\n" for team in room.teams: for player in team.players: msg += f"{player.lounge_name} {player.score}\n" @@ -422,7 +584,7 @@ async def remove_player(self, interaction: discord.Interaction, member: discord. squad = mogi.check_player(member) if squad is None: - await interaction.followup.send(f"{member.display_name} is not currently in this event; type `/c` to join") + await interaction.followup.send(f"{member.display_name} is not currently in this event") return mogi.teams.remove(squad) msg = "Staff has removed " @@ -432,54 +594,170 @@ async def remove_player(self, interaction: discord.Interaction, member: discord. await interaction.followup.send(msg) + @app_commands.command(name="ping_staff") + @app_commands.guild_only() + @app_commands.checks.cooldown(1, 300, key=lambda i: i.user.id) + async def ping_staff(self, interaction: discord.Interaction, role: str): + """Pings the specified staff role for help.""" + if not isinstance(interaction.channel, discord.Thread): + await interaction.response.send_message(f"Cannot use this command here.", ephemeral=True) + return + if is_restricted(interaction.user, self.bot.config): + await interaction.response.send_message(f"You are restricted from using this command.", ephemeral=True) + return + if role not in self.helper_staff_roles: + await interaction.response.send_message(f"You are not allowed to ping this role for help. Valid roles to ping: `{', '.join(self.helper_staff_roles)}`", ephemeral=True) + return + await interaction.response.send_message(f"{self.helper_staff_roles[role].mention}, {interaction.user.mention} is requesting help.", allowed_mentions=discord.AllowedMentions(roles=True)) + + @ping_staff.autocomplete('role') + async def ping_staff_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + return [ + app_commands.Choice(name=role_name, value=role_name) + for role_name in self.helper_staff_roles if current.lower() in role_name.lower() + ] + + def allow_new_mogis(self): + self.LAUNCH_NEW_EVENTS = True + @app_commands.command(name="annul_current_mogi") @app_commands.guild_only() - async def annul_current_mogi(self, interaction: discord.Interaction): - """The mogi currently gathering will be deleted. The queue resumes at the next hour. Staff use only.""" - self.scheduled_events = {} - self.ongoing_events = {} - curr_time = datetime.now(timezone.utc) - truncated_time = curr_time.replace( - minute=0, second=0, microsecond=0) - self.QUEUE_TIME_BLOCKER = truncated_time + timedelta(hours=1) + async def annul_current_mogi(self, interaction: discord.Interaction, resume_mogis_after: bool): + """The mogi currently gathering will be deleted. resume_mogis_after determines whether future mogis will be scheduled. Staff use only.""" + self.next_event = None + self.ongoing_event = None + self.LAUNCH_NEW_EVENTS = False + msg = "The current mogi has been cancelled, " + if resume_mogis_after: + next_event_open_time = self.compute_next_event_open_time() + next_event_start_time = ( + next_event_open_time + self.QUEUE_OPEN_TIME) + delay = (next_event_start_time - + datetime.now(timezone.utc)).total_seconds() + asyncio.get_event_loop().call_later(delay, self.allow_new_mogis) + msg += f"the queue will resume at {next_event_start_time}." + else: + msg += "future mogis will not be started." await self.lockdown(self.MOGI_CHANNEL) - await interaction.response.send_message("The current Mogi has been canceled, the queue will resume at the next hour.") + await interaction.response.send_message(msg) @app_commands.command(name="pause_mogi_scheduling") @app_commands.guild_only() async def pause_mogi_scheduling(self, interaction: discord.Interaction): """The mogi that is currently gathering will continue to work. Future mogis cannot be scheduled. Staff use only.""" - curr_time = datetime.now(timezone.utc) - self.QUEUE_TIME_BLOCKER = curr_time + timedelta(weeks=52) + self.LAUNCH_NEW_EVENTS = False await interaction.response.send_message("Future Mogis will not be started.") @app_commands.command(name="resume_mogi_scheduling") @app_commands.guild_only() async def resume_mogi_scheduling(self, interaction: discord.Interaction): """Mogis will begin to be scheduled again. Staff use only.""" - curr_time = datetime.now(timezone.utc) - self.QUEUE_TIME_BLOCKER = curr_time + self.LAUNCH_NEW_EVENTS = True await interaction.response.send_message("Mogis will resume scheduling.") + @app_commands.command(name="change_event_time") + @app_commands.guild_only() + async def change_event_time(self, interaction: discord.Interaction, event_time: int): + """Change the amount of time for each event in the queue. Staff use only.""" + if event_time > 15: + self.QUEUE_OPEN_TIME = timedelta(minutes=event_time) + self.JOINING_TIME = timedelta( + minutes=event_time) - self.EXTENSION_TIME + await interaction.response.send_message( + f"The amount of time for each mogi has been changed to {event_time} minutes.") + else: + await interaction.response.send_message("Please enter a number of minutes greater than 15.") + + @app_commands.command(name="change_mmr_threshold") + @app_commands.guild_only() + async def change_mmr_threshold(self, interaction: discord.Interaction, mmr_threshold: int): + """Change the maximum MMR gap allowed for a room. Staff use only.""" + self.room_mmr_threshold = mmr_threshold + try: + with open(MMR_THRESHOLD_PKL, 'wb') as f: + dill.dump(self.room_mmr_threshold, f) + except: + print(traceback.format_exc()) + await interaction.response.send_message(f"MMR Threshold for Queue Rooms has been modified to {mmr_threshold} MMR.") + + @app_commands.command(name="peek_bot_config") + @app_commands.guild_only() + async def peek_bot_config(self, interaction: discord.Interaction): + """View the configured values for the Queue System. Staff use only.""" + msg = "" + msg += f"LOUNGE TYPE: {self.bot.config['lounge']}\n" + if common.SERVER is common.Server.MKW: + msg += f"TRACK TYPE: {self.TRACK_TYPE}\n" + msg += f"CURRENT SERVER: {self.GUILD}\n" + msg += f"GENERAL CHANNEL: {self.GENERAL_CHANNEL}\n" + msg += f"LIST CHANNEL: {self.LIST_CHANNEL}\n" + msg += f"JOIN CHANNEL: {self.MOGI_CHANNEL}\n" + msg += f"SUB CHANNEL: {self.SUB_CHANNEL}\n" + msg += f"HISTORY CHANNEL: {self.HISTORY_CHANNEL}\n" + + admins = [] + for m in self.bot.config["admin_roles"]: + role = self.GUILD.get_role(m) + if role: + admins.append(role.name) + msg += f"ADMIN ROLES: {', '.join(admins)}\n" + + helper_staff = [] + for m in self.bot.config["helper_staff_roles"]: + role = self.GUILD.get_role(m) + if role: + helper_staff.append(role.name) + msg += f"HELPER STAFF ROLES: {', '.join(helper_staff)}\n" + + members_for_channels = [] + for m in self.bot.config["members_for_channels"]: + member = self.GUILD.get_member(m) + if member: + members_for_channels.append(member.name) + msg += f"MEMBERS ADDED TO EACH ROOM: {', '.join(members_for_channels)}\n" + + roles_for_channels = [] + for m in self.bot.config["roles_for_channels"]: + role = self.GUILD.get_role(m) + if role: + roles_for_channels.append(role.name) + msg += f"ROLES ADDED TO EACH ROOM: {', '.join(roles_for_channels)}\n" + + msg += f"PLACEMENT PLAYER STARTING MMR: {self.PLACEMENT_PLAYER_MMR}\n" + msg += f"SITE URL: {self.URL}\n" + msg += f"FIRST EVENT TIME: {self.FIRST_EVENT_TIME}\n" + msg += f"TIME BETWEEN EVENTS: {self.QUEUE_OPEN_TIME} minutes\n" + msg += f"DISPLAY TIME OFFSET MINUTES FROM JOIN TIME END: {self.DISPLAY_OFFSET_MINUTES} minutes\n" + msg += f"EVENT JOINING TIME: {self.JOINING_TIME} minutes\n" + msg += f"EXTENSION TIME: {self.EXTENSION_TIME} minutes\n" + msg += f"ROOM JOIN PENALTY TIME: {self.ROOM_JOIN_PENALTY_TIME} minutes\n" + msg += f"EVENT LIFETIME: {self.MOGI_LIFETIME} minutes\n" + msg += f"EXTRA SUB RANGE ALLOWANCE: +- {self.SUB_RANGE_MMR_ALLOWANCE} MMR\n" + msg += f"SUB MESSAGE LIFETIME: {self.SUB_MESSAGE_LIFETIME_SECONDS} seconds\n" + msg += f"ROOM MMR THRESHOLD: {self.room_mmr_threshold}" + await interaction.response.send_message(msg) + @app_commands.command(name="reset_bot") @app_commands.guild_only() async def reset_bot(self, interaction: discord.Interaction): """Resets the bot. Staff use only.""" - self.scheduled_events = {} - self.ongoing_events = {} - self.old_events = {} - curr_time = datetime.now(timezone.utc) - self.QUEUE_TIME_BLOCKER = curr_time + self.next_event = None + self.ongoing_event = None + self.old_events = [] + self.LAUNCH_NEW_EVENTS = True await interaction.response.send_message("All events have been deleted. Queue will restart shortly.") @commands.command(name="schedule_sq_times") @commands.guild_only() async def schedule_sq_times(self, ctx, timestamps: commands.Greedy[int]): """Saves a list of sq times to skip over. Input a list of unix utc timestamps. Staff use only.""" - if not await self.has_roles(ctx.author, ctx.guild.id, ctx.bot.config): + if not self.is_staff(ctx.author) and not await self.bot.is_owner(ctx.author): + self.queue_or_send( + ctx, "You do not have permission to use this command.") return - msg = "List of new Dates:\n" + msg = "The queue will skip events THAT START AT THE TIMESTAMP (not when the queue pops)\n" + msg += "List of new Dates:\n" curr_time = datetime.now(timezone.utc) new_sq_dates = [] @@ -524,63 +802,69 @@ async def clear_sq_times(self, interaction: discord.Interaction): await interaction.response.send_message("Cleared list of Squad Queue Times.") - # check if user has roles defined in config.json - async def has_roles(self, member: discord.Member, guild_id: int, config): - if str(guild_id) not in config["admin_roles"].keys(): - return True - for role in member.roles: - if role.name in config["admin_roles"][str(guild_id)]: - return True - return False - - # command to add staff to room thread channels; users can't add new users to private threads, - # so the bot has to with this command - @commands.command() - @commands.cooldown(1, 60, commands.BucketType.channel) - async def staff(self, ctx): - """Calls staff to the current channel. Only works in thread channels for SQ rooms.""" - is_room_thread = False - for mogi in self.ongoing_events.values(): - if mogi.is_room_thread(ctx.channel.id): - is_room_thread = True - break - for mogi in self.old_events.values(): - if mogi.is_room_thread(ctx.channel.id): - is_room_thread = True - break - if not is_room_thread: - return - if str(ctx.guild.id) not in ctx.bot.config["staff_roles"].keys(): - await ctx.send("There is no Lounge Staff role configured for this server") - return - lounge_staff_roles = ctx.bot.config["staff_roles"][str(ctx.guild.id)] - mentions = " ".join( - [ctx.guild.get_role(role).mention for role in lounge_staff_roles]) - await ctx.send(mentions) + @app_commands.command(name="update_tier_info") + @app_commands.guild_only() + async def update_tier_info(self, interaction: discord.Interaction): + """Updates the mmr divisions that denote each tier. Staff use only.""" + msg = await self.get_ladder_info() + await interaction.response.send_message(msg) - async def end_voting(self): + async def get_ladder_info(self): + timeout = aiohttp.ClientTimeout(total=10) + url = "https://mkwlounge.gg/api/ladderclass.php?ladder_type=" + self.TRACK_TYPE + msg = "" + try: + async with aiohttp.ClientSession( + timeout=timeout, + auth=aiohttp.BasicAuth( + "username", "password")) as session: + async with session.get(url, headers=headers) as resp: + if resp.status != 200: + raise Exception( + "Fetch for tier info has failed, bad status code") + result = await resp.json() + if result['status'] != "success": + raise Exception( + "Fetch for tier info has failed, Status: Failure") + self.TIER_INFO = result["results"] + msg += "Fetch for Tier Info Successful.\n" + for tier in self.TIER_INFO: + boundary = "" + if tier["minimum_mmr"]: + boundary += f"{tier['minimum_mmr']}-" + else: + boundary += "<" + if tier["maximum_mmr"]: + boundary += f"{tier['maximum_mmr']}" + else: + boundary = f">{tier['minimum_mmr']}" + msg += f"T{tier['ladder_order']} Boundary: {boundary}\n" + print(msg, flush=True) + return msg + except Exception as e: + print(traceback.format_exc()) + + @staticmethod + async def end_voting(mogi: Mogi): """Ends voting in all rooms with ongoing votes.""" try: - index = 0 - for mogi in self.ongoing_events.values(): - index += 1 - for room in mogi.rooms: + if mogi is not None: + for index, room in enumerate(mogi.rooms, 1): if not room or not room.view: print( f"Skipping room {index} in function end_voting.", flush=True) continue await room.view.find_winner() except Exception as e: - print(e, flush=True) + print(traceback.format_exc()) - async def write_history(self): + @staticmethod + async def write_history(mogi: Mogi, history_channel: discord.TextChannel): """Writes the teams, tier and average of each room per hour.""" try: - index = 0 - for mogi in self.ongoing_events.values(): - index += 1 - await self.HISTORY_CHANNEL.send(f"{discord.utils.format_dt(mogi.start_time)} Rooms") - for room in mogi.rooms: + if mogi is not None: + await history_channel.send(f"{discord.utils.format_dt(mogi.display_time)} Rooms") + for index, room in enumerate(mogi.rooms, 1): if not room or not room.view: print( f"Skipping room {index} in function write_history.", flush=True) @@ -589,160 +873,191 @@ async def write_history(self): msg += f"{room.thread.jump_url}\n" msg += room.view.teams_text msg += "ㅤ" - await self.HISTORY_CHANNEL.send(msg) + await history_channel.send(msg) except Exception as e: - print(e, flush=True) + print(traceback.format_exc()) # make thread channels while the event is gathering instead of at the end, # since discord only allows 50 thread channels to be created per 5 minutes. async def check_room_channels(self, mogi): - num_teams = mogi.count_registered() - num_rooms = int(num_teams / (12/mogi.size)) num_created_rooms = len(mogi.rooms) - if num_created_rooms >= num_rooms: + if num_created_rooms >= mogi.max_possible_rooms: return - for i in range(num_created_rooms, num_rooms): - room_name = f"{mogi.start_time.month}/{mogi.start_time.day}, {mogi.start_time.hour}:00:00 - Room {i+1}" + for i in range(num_created_rooms, mogi.max_possible_rooms): + display_time = mogi.display_time + minute = display_time.minute + if len(str(minute)) == 1: + minute = '0' + str(minute) + room_name = f"{display_time.month}/{display_time.day}, {display_time.hour}:{minute}:00 - Room {i+1}" try: - room_channel = await mogi.mogi_channel.create_thread(name=room_name, - auto_archive_duration=60, - invitable=False) + room_channel = await self.GENERAL_CHANNEL.create_thread(name=room_name, + auto_archive_duration=60, + invitable=False) except Exception as e: - print(e, flush=True) + print(traceback.format_exc()) err_msg = f"\nAn error has occurred while creating a room channel:\n{e}" await mogi.mogi_channel.send(err_msg) return mogi.rooms.append(Room(None, i+1, room_channel)) + @staticmethod + async def handle_voting_and_history(mogi: Mogi, history_channel: discord.TextChannel): + # We could have used asyncio.call_later(120, handle_voting_and_history) in the caller's code + await asyncio.sleep(120) + await SquadQueue.end_voting(mogi) + await SquadQueue.write_history(mogi, history_channel) + # add teams to the room threads that we have already created - async def add_teams_to_rooms(self, mogi, open_time: int, started_automatically=False): + async def add_teams_to_rooms(self, mogi: Mogi, open_time: int, started_automatically=False): if open_time >= 60 or open_time < 0: await mogi.mogi_channel.send("Please specify a valid time (in minutes) for rooms to open (00-59)") return if mogi.making_rooms_run and started_automatically: return - num_rooms = int(mogi.count_registered() / (12/mogi.size)) - if num_rooms == 0: + if mogi.max_possible_rooms == 0: + self.ongoing_event = None await mogi.mogi_channel.send(f"Not enough players to fill a single room! This mogi will be cancelled.") - self.scheduled_events = {} - self.ongoing_events = {} return - await self.lockdown(mogi.mogi_channel) + + was_gathering = mogi.gathering mogi.making_rooms_run = True - if mogi.gathering: - mogi.gathering = False - await mogi.mogi_channel.send("Mogi is now closed; players can no longer join or drop from the event") + mogi.gathering = False - pen_time = open_time + 5 - start_time = open_time + 10 - while pen_time >= 60: - pen_time -= 60 - while start_time >= 60: - start_time -= 60 - teams_per_room = int(12/mogi.size) - num_teams = int(num_rooms * teams_per_room) - final_list = mogi.confirmed_list()[0:num_teams] - sorted_list = sorted(final_list, reverse=True) + await self.lockdown(mogi.mogi_channel) + if was_gathering: + await mogi.mogi_channel.send("Mogi is now closed; players can no longer join or drop from the event") extra_members = [] - if str(mogi.mogi_channel.guild.id) in self.bot.config["members_for_channels"].keys(): - extra_members_ids = self.bot.config["members_for_channels"][str( - mogi.mogi_channel.guild.id)] - for m in extra_members_ids: - extra_members.append(mogi.mogi_channel.guild.get_member(m)) - - rooms = mogi.rooms - for i in range(num_rooms): - msg = f"`Room {i+1} - Player List`\n" - mentions = "" - start_index = int(i*teams_per_room) - player_list = [] - for j in range(teams_per_room): - msg += f"`{j+1}.` " - team = sorted_list[start_index+j] - player_list.append( - sorted_list[start_index+j].get_first_player()) - msg += ", ".join([p.lounge_name for p in team.players]) - msg += f" ({int(team.avg_mmr)} MMR)\n" - mentions += " ".join([p.member.mention for p in team.players]) - mentions += " " - room_msg = msg - mentions += " ".join([m.mention for m in extra_members if m is not None]) - room_msg += "\nVote for format FFA, 2v2, 3v3, or 4v4.\n" - room_msg += "\nIf you need staff's assistance, use the `!staff` command in this channel.\n" - room_msg += mentions - try: - curr_room = rooms[i] - room_channel = curr_room.thread - curr_room.teams = sorted_list[start_index:start_index+teams_per_room] - await room_channel.send(room_msg) - view = VoteView(player_list, room_channel, mogi) - curr_room.view = view - curr_room.mmr_low = player_list[11].mmr - curr_room.mmr_high = player_list[0].mmr - await room_channel.send(view=view) - except Exception as e: - print(e, flush=True) - err_msg = f"\nAn error has occurred while creating the room channel; please contact your opponents in DM or another channel\n" - err_msg += mentions - msg += err_msg - room_channel = None + + for m in self.bot.config["members_for_channels"]: + extra_members.append(mogi.mogi_channel.guild.get_member(m)) + for r in self.bot.config["roles_for_channels"]: + extra_members.append(mogi.mogi_channel.guild.get_role(r)) + + # Support for both server to implement their own algorithm for a player list being allowed + # For now, both servers have a simple rating range check that a list of players must meet, + # but each server could implement more complex checks on a given list of players. + if common.SERVER is common.Server.MKW: + def allowed_players_check(players): + return mkw_players_allowed(players, self.room_mmr_threshold) + elif common.SERVER is common.Server.MK8DX: + def allowed_players_check(players): + return mk8dx_players_allowed(players, self.room_mmr_threshold) + + all_confirmed_players = mogi.players_on_confirmed_teams() + first_late_player_index = ( + mogi.num_players//mogi.players_per_room) * mogi.players_per_room + regular_player_list = all_confirmed_players[:first_late_player_index] + late_player_list = all_confirmed_players[first_late_player_index:] + proposed_list = sorted(mogi.generate_proposed_list( + allowed_players_check), reverse=True) + await mogi.populate_host_fcs() + for room_number, room_players in enumerate(divide_chunks(proposed_list, mogi.players_per_room), 1): + msg = f"`Room {room_number} - Player List`\n" + for player_num, player in enumerate(room_players, 1): + added_str = ": **Added from late players**" if player in late_player_list else "" + msg += f"""`{player_num}.` {player.lounge_name} ({player.mmr} MMR){added_str}\n""" + if not allowed_players_check(room_players): + msg += f"\nThe mmr gap in the room is higher than the allowed threshold of {self.room_mmr_threshold} MMR, this room has been cancelled." + else: + curr_room = mogi.rooms[room_number - 1] + curr_room.teams = [Team([p]) for p in room_players] + curr_room.create_host_list() + player_mentions = " ".join([p.mention for p in room_players]) + extra_member_mentions = " ".join( + [m.mention for m in extra_members if m is not None]) + room_msg = "" + if common.SERVER is common.Server.MKW: + room_msg += f"""{msg} +Vote for format FFA, 2v2, 3v3, 4v4, or 6v6. +{player_mentions} {extra_member_mentions} + +If you need staff's assistance, use the `/ping_staff` command in this channel.""" + elif common.SERVER is common.Server.MK8DX: + room_msg += f"""{msg} +Vote for format FFA, 2v2, 3v3, 4v4. +{player_mentions} {extra_member_mentions} + +If you need staff's assistance, use the `/ping_staff` command in this channel.""" + try: + await curr_room.thread.send(room_msg) + view = VoteView(room_players, curr_room.thread, + mogi, curr_room, self.ROOM_JOIN_PENALTY_TIME, self.TIER_INFO) + curr_room.view = view + await curr_room.thread.send(view=view) + except discord.DiscordException: + err_msg = f"\nAn error has occurred while creating the room channel; please contact your opponents in DM or another channel\n" + err_msg += player_mentions + extra_member_mentions + msg += err_msg + print(traceback.format_exc()) + try: await mogi.mogi_channel.send(msg) - except Exception as e: + except discord.DiscordException: print( - f"Mogi Channel message for room {i+1} has failed to send.", flush=True) - print(e, flush=True) - if num_teams < mogi.count_registered(): - missed_teams = mogi.confirmed_list( - )[num_teams:mogi.count_registered()] + f"Mogi Channel message for room {room_number} has failed to send.", flush=True) + print(traceback.format_exc()) + + # Compute the list of "late" players that didn't get into any room + not_in_proposed_list = [ + p for p in all_confirmed_players if p not in proposed_list] + if len(not_in_proposed_list) > 0: msg = "`Late players:`\n" - for i in range(len(missed_teams)): - msg += f"`{i+1}.` " - msg += ", ".join([p.lounge_name for p in missed_teams[i].players]) - msg += f" ({int(missed_teams[i].avg_mmr)} MMR)\n" + for i, player in enumerate(not_in_proposed_list, 1): + removed_str = ": **Removed from player list**" if player in regular_player_list else "" + msg += f"`{i}.` {player.lounge_name} ({int(player.mmr)} MMR) {removed_str}\n" try: await mogi.mogi_channel.send(msg) - except Exception as e: + except discord.DiscordException: print("Late Player message has failed to send.", flush=True) - print(e, flush=True) - await asyncio.sleep(120) - await self.end_voting() - await self.write_history() + print(traceback.format_exc()) + + # We could have used asyncio.call_later(120, handle_voting_and_history) + # and removed asyncio.sleep(120) in handle_voting_and_history + asyncio.create_task(SquadQueue.handle_voting_and_history( + self.ongoing_event, self.HISTORY_CHANNEL)) + self.old_events.append(self.ongoing_event) + self.ongoing_event = None - async def check_num_teams(self, mogi): + async def check_num_teams(self, mogi: Mogi): if not mogi.gathering or not mogi.is_automated: return cur_time = datetime.now(timezone.utc) - if mogi.start_time - self.QUEUE_OPEN_TIME + self.JOINING_TIME <= cur_time: - numLeftoverTeams = mogi.count_registered() % int((12/mogi.size)) - if numLeftoverTeams == 0: + if mogi.start_time <= cur_time: + num_leftover_teams = mogi.count_registered() % int( + (12 / mogi.max_player_per_team)) + if num_leftover_teams == 0: mogi.gathering = False await self.lockdown(mogi.mogi_channel) - await mogi.mogi_channel.send("A sufficient amount of players has been reached, so the mogi has been closed to extra players. Rooms will be made within the next minute.") + await mogi.mogi_channel.send( + "A sufficient amount of players has been reached, so the mogi has been closed to extra players. Rooms will be made within the next minute.") async def ongoing_mogi_checks(self): - for mogi in self.ongoing_events.values(): + mogi = self.ongoing_event + if mogi is not None: # If it's not automated, not started, we've already started making the rooms, don't run this async with self.LOCK: if not mogi.is_automated or not mogi.started or mogi.making_rooms_run: return cur_time = datetime.now(timezone.utc) - if (mogi.start_time - self.QUEUE_OPEN_TIME + self.JOINING_TIME + self.EXTENSION_TIME) <= cur_time: + force_start_time = mogi.start_time + \ + self.EXTENSION_TIME + mogi.additional_extension + if force_start_time <= cur_time: mogi.gathering = False - if mogi.start_time - self.QUEUE_OPEN_TIME + self.JOINING_TIME <= cur_time and mogi.gathering: + elif mogi.start_time <= cur_time and mogi.gathering: # check if there are an even amount of teams since we are past the queue time - numLeftoverTeams = mogi.count_registered() % int((12/mogi.size)) - if numLeftoverTeams == 0: + num_leftover_teams = mogi.count_registered() % int( + (12 / mogi.max_player_per_team)) + if num_leftover_teams == 0: mogi.gathering = False else: if int(cur_time.second / 20) == 0: - force_time = mogi.start_time - self.QUEUE_OPEN_TIME + \ - self.JOINING_TIME + self.EXTENSION_TIME - minutes_left = int( - (force_time - cur_time).seconds/60) - x_teams = int(int(12/mogi.size) - numLeftoverTeams) - await mogi.mogi_channel.send(f"Need {x_teams} more player(s) to start immediately. Starting in {minutes_left + 1} minute(s) regardless.") + minutes_left = (force_start_time - + cur_time).seconds // 60 + x_teams = int( + int(12 / mogi.max_player_per_team) - num_leftover_teams) + await mogi.mogi_channel.send( + f"Need {x_teams} more player(s) to start immediately. Starting in {minutes_left + 1} minute(s) regardless.") if not mogi.gathering: await self.delete_list_messages(0) await mogi.mogi_channel.send("Mogi is now closed; players can no longer join or drop from the event") @@ -750,26 +1065,27 @@ async def ongoing_mogi_checks(self): async def scheduler_mogi_start(self): cur_time = datetime.now(timezone.utc) - for guild in self.scheduled_events.values(): - to_remove = [] # Keep a list of indexes to remove - can't remove while iterating - for i, mogi in enumerate(guild): - if (mogi.start_time - self.QUEUE_OPEN_TIME) < cur_time: - if mogi.mogi_channel in self.ongoing_events.keys() and self.ongoing_events[mogi.mogi_channel].gathering: - to_remove.append(i) - await mogi.mogi_channel.send(f"Because there is an ongoing event right now, the following event has been removed:\n{self.get_event_str(mogi)}\n") - else: - if mogi.mogi_channel in self.ongoing_events.keys(): - if self.ongoing_events[mogi.mogi_channel].started: - self.old_events[mogi.mogi_channel] = self.ongoing_events[mogi.mogi_channel] - del self.ongoing_events[mogi.mogi_channel] - to_remove.append(i) - mogi.started = True - mogi.gathering = True - self.ongoing_events[mogi.mogi_channel] = mogi - await self.unlockdown(mogi.mogi_channel) - await mogi.mogi_channel.send(f"A queue is gathering for the mogi {discord.utils.format_dt(mogi.start_time, style='R')} - @here Type `/c` to join, and `/d` to drop.") - for ind in reversed(to_remove): - del guild[ind] + next_mogi = self.next_event + if next_mogi is not None and (next_mogi.start_time - self.JOINING_TIME) < cur_time: + # We are trying to launch the event - fail or not, we set next event to None + self.next_event = None + # There is an ongoing event, but people are still queueing, so remove it and fail + if self.ongoing_event is not None and self.ongoing_event.gathering: + await next_mogi.mogi_channel.send( + f"Because there is an ongoing event right now, the following event has been removed:\n{self.get_event_str(next_mogi)}\n") + return + # There is an ongoing event, but it has already started, so add it to the old events + # This is potentially an issue - we should be checking if make rooms has been run, not if it's started...? + if self.ongoing_event is not None and self.ongoing_event.started: + self.old_events.append(self.ongoing_event) + + # Put the next mogi as the current event and launch it + self.ongoing_event = next_mogi + self.ongoing_event.started = True + self.ongoing_event.gathering = True + await self.unlockdown(self.ongoing_event.mogi_channel) + await self.ongoing_event.mogi_channel.send( + f"A queue is gathering for the mogi {discord.utils.format_dt(self.ongoing_event.start_time, style='R')} - Type `/c` to join, `/ch` to join and volunteer to host, and `/d` to drop.") @tasks.loop(seconds=20.0) async def sqscheduler(self): @@ -777,57 +1093,63 @@ async def sqscheduler(self): # It may seem silly to do try/except Exception, but this coroutine **cannot** fail # This coroutine *silently* fails and stops if exceptions aren't caught - an annoying abtraction of asyncio # This is unacceptable considering people are relying on these mogis to run, so we will not allow this routine to stop + try: + if self.ongoing_event is None: + await self.schedule_que_event() + except Exception as e: + print(traceback.format_exc()) try: await self.scheduler_mogi_start() except Exception as e: - print(e) + print(traceback.format_exc()) try: await self.ongoing_mogi_checks() except Exception as e: - print(e) + print(traceback.format_exc()) - @tasks.loop(minutes=1) - async def que_scheduler(self): - try: - if not self.scheduled_events or len(self.scheduled_events[self.GUILD]) == 0: - await self.schedule_que_event() - except Exception as e: - print(e) + def compute_next_event_open_time(self): + cur_time = datetime.now(timezone.utc) + time_elapsed = cur_time - self.FIRST_EVENT_TIME + num_launched_events = time_elapsed // self.QUEUE_OPEN_TIME + next_event_open_time = self.FIRST_EVENT_TIME + \ + (self.QUEUE_OPEN_TIME * num_launched_events) + return next_event_open_time async def schedule_que_event(self): - """Schedules queue for the next hour in the given channel.""" - + """Schedules next queue in the SquadQueue mogi queueing channel.""" if self.GUILD is not None: - curr_time = datetime.now(timezone.utc) - truncated_time = curr_time.replace( - minute=0, second=0, microsecond=0) - next_hour = truncated_time + timedelta(hours=1) - if len(self.sq_times) > 0 and next_hour == self.sq_times[0]: - self.sq_times.pop(0) - self.QUEUE_TIME_BLOCKER = next_hour - await self.MOGI_CHANNEL.send("Squad Queue is currently going on at this hour! The queue will remain closed.") - if curr_time < self.QUEUE_TIME_BLOCKER: - # print(f"Mogi had been blocked from starting before the time limit {self.QUEUE_TIME_BLOCKER}", flush=True) + if not self.LAUNCH_NEW_EVENTS: + # (f"Not allowed to launch new events.", flush=True) return - if datetime.now().minute >= self.bot.config["JOINING_TIME"]: - # print("Hourly Que is too late, starting Que at next hour", flush=True) + next_event_open_time = self.compute_next_event_open_time() + next_event_start_time = next_event_open_time + self.JOINING_TIME + next_event_display_time = next_event_start_time + self.DISPLAY_OFFSET_MINUTES + # We don't want to schedule the next event if it would open after it's joining period and during its extension period + if next_event_start_time < datetime.now(timezone.utc): return - for mogi in self.ongoing_events.values(): - if mogi.start_time == next_hour: - return - event_start_time = next_hour.astimezone() - self.QUEUE_OPEN_TIME - if event_start_time < discord.utils.utcnow(): - event_start_time = discord.utils.utcnow() + timedelta(minutes=1) - - mogi = Mogi(1, 1, self.MOGI_CHANNEL, is_automated=True, - start_time=next_hour) - - if self.GUILD not in self.scheduled_events.keys(): - self.scheduled_events[self.GUILD] = [] - - self.scheduled_events[self.GUILD].append(mogi) + if len(self.sq_times) > 0 and next_event_open_time == self.sq_times[0]: + self.sq_times.pop(0) + self.next_event = None + self.ongoing_event = None + self.LAUNCH_NEW_EVENTS = False + next_event_start_time = next_event_open_time + self.QUEUE_OPEN_TIME + delay = (next_event_start_time - + datetime.now(timezone.utc)).total_seconds() + print(next_event_start_time, delay, flush=True) + asyncio.get_event_loop().call_later(delay, self.allow_new_mogis) + await self.lockdown(self.MOGI_CHANNEL) + await self.MOGI_CHANNEL.send( + "Squad Queue is currently going on! The queue will remain closed.") + return + self.next_event = Mogi(sq_id=1, + max_players_per_team=1, + players_per_room=12, + mogi_channel=self.MOGI_CHANNEL, + is_automated=True, + start_time=next_event_start_time, + display_time=next_event_display_time) - print(f"Started Queue for {next_hour}", flush=True) + print(f"Started Queue for {next_event_start_time}", flush=True) @tasks.loop(minutes=1) async def delete_old_mogis(self): @@ -835,22 +1157,43 @@ async def delete_old_mogis(self): try: curr_time = datetime.now(timezone.utc) mogi_lifetime = timedelta(minutes=self.MOGI_LIFETIME) - delete_queue = [] - for mogi in self.old_events.values(): - if curr_time - mogi_lifetime > mogi.start_time: - delete_queue.append(mogi) + delete_queue = [m for m in self.old_events if ( + curr_time - mogi_lifetime) > m.start_time] for mogi in delete_queue: print( f"Deleting {mogi.start_time} Mogi at {curr_time}", flush=True) - del self.old_events[mogi.mogi_channel] + self.old_events.remove(mogi) + except Exception as e: + print(traceback.format_exc()) + + @tasks.loop(hours=24) + async def refresh_helper_roles(self): + """Refreshes the helper staff role names for the /ping_staff command using the role IDs in the config""" + try: + helper_staff_role_ids = self.bot.config["helper_staff_roles"] + # In my experience, large servers experience caching issues. + # This is a forced API call which guarantees the role information will be up-to-date. + all_roles = await self.GUILD.fetch_roles() + updated_roles = {} + for role_id in helper_staff_role_ids: + needle: discord.Role = discord.utils.find( + lambda n: role_id == n.id, all_roles) + if needle is not None: + updated_roles[needle.name] = needle + # As a fail safe, if we didn't add any new roles, either due to a misconfiguration or an internal discord + # issue, only update if we found one or more of the roles + if len(updated_roles) > 0: + # We use clear and update to ensure any *references* to the original dictionary are updated + self.helper_staff_roles.clear() + self.helper_staff_roles.update(updated_roles) except Exception as e: - print(e, flush=True) + print(traceback.format_exc()) - def get_event_str(self, mogi): + def get_event_str(self, mogi: Mogi): mogi_time = discord.utils.format_dt(mogi.start_time, style="F") mogi_time_relative = discord.utils.format_dt( mogi.start_time, style="R") - return (f"`#{mogi.sq_id}` **{mogi.size}v{mogi.size}:** {mogi_time} - {mogi_time_relative}") + return f"`#{mogi.sq_id}` **{mogi.max_player_per_team}v{mogi.max_player_per_team}:** {mogi_time} - {mogi_time_relative}" @commands.command(name="sync") @commands.is_owner() @@ -874,9 +1217,18 @@ async def debug_add_team(self, ctx, members: commands.Greedy[discord.Member]): or not await self.is_gathering(ctx, mogi)): return - check_players = [ctx.author] + member = ctx.author + url = self.URL + if not self.is_production: + if common.SERVER is common.Server.MK8DX: + member = await self.bot.fetch_user(318637887597969419) + url = "https://www.mk8dx-lounge.com" + elif common.SERVER is common.Server.MKW: + member = await self.bot.fetch_user(82862780591378432) + url = "https://www.mkwlounge.gg" + check_players = [member] check_players.extend(members) - players = await mk8dx_150cc_mmr(self.URL, check_players) + players = await get_mmr(url, check_players, self.TRACK_TYPE) for i in range(0, 12): player = Player( players[0].member, f"{players[0].lounge_name}{i + 1}", players[0].mmr + (10 * i)) @@ -888,9 +1240,29 @@ async def debug_add_team(self, ctx, members: commands.Greedy[discord.Member]): await self.check_room_channels(mogi) await self.check_num_teams(mogi) + @commands.command(name="debug_add_many_ratings") + @commands.is_owner() + async def debug_add_many_ratings(self, ctx, *ratings: str): + mogi = self.get_mogi(ctx) + if mogi is None: + return + if (not await self.is_started(ctx, mogi) + or not await self.is_gathering(ctx, mogi)): + return + for i, rating in enumerate(ratings, 1): + if rating.isdecimal(): + player = Player(ctx.author, f"{ctx.author.display_name} {i}", int( + rating), confirmed=True) + mogi.teams.append(Team([player])) + msg = f"Players added with the following ratings: {' '.join(['`' + r + '`' for r in ratings])}" + await self.queue_or_send(ctx, msg) + await self.check_room_channels(mogi) + await self.check_num_teams(mogi) + @commands.command(name="debug_add_many_players") @commands.is_owner() async def debug_add_many_players(self, ctx, members: commands.Greedy[discord.Member]): + num_times = 25 mogi = self.get_mogi(ctx) if mogi is None: return @@ -898,16 +1270,25 @@ async def debug_add_many_players(self, ctx, members: commands.Greedy[discord.Mem or not await self.is_gathering(ctx, mogi)): return - check_players = [ctx.author] + member = ctx.author + url = self.URL + if not self.is_production: + if common.SERVER is common.Server.MK8DX: + member = await self.bot.fetch_user(318637887597969419) + url = "https://www.mk8dx-lounge.com" + elif common.SERVER is common.Server.MKW: + member = await self.bot.fetch_user(82862780591378432) + url = "https://www.mkwlounge.gg" + check_players = [member] check_players.extend(members) - players = await mk8dx_150cc_mmr(self.URL, check_players) - for i in range(0, 100): + players = await get_mmr(url, check_players, self.TRACK_TYPE) + for i in range(0, num_times): player = Player( players[0].member, f"{players[0].lounge_name}{i + 1}", players[0].mmr + (10 * i)) player.confirmed = True squad = Team([player]) mogi.teams.append(squad) - msg = f"{players[0].lounge_name} added 100 times." + msg = f"{players[0].lounge_name} added {num_times} times." await self.queue_or_send(ctx, msg) await self.check_room_channels(mogi) await self.check_num_teams(mogi) @@ -915,18 +1296,14 @@ async def debug_add_many_players(self, ctx, members: commands.Greedy[discord.Mem @commands.command(name="debug_start_rooms") @commands.is_owner() async def debug_start_rooms(self, ctx): - truncated_time = datetime.now(timezone.utc).replace( - minute=0, second=0, microsecond=0) - next_hour = truncated_time + timedelta(hours=1) - for mogi in self.ongoing_events.values(): - if mogi.start_time == next_hour: - await self.add_teams_to_rooms(mogi, (mogi.start_time.minute) % 60, True) - return - for mogi in self.old_events.values(): - if mogi.start_time == next_hour: - await self.add_teams_to_rooms(mogi, (mogi.start_time.minute) % 60, True) - return + cur_mogi = self.ongoing_event + if cur_mogi is not None: + await self.add_teams_to_rooms(cur_mogi, (cur_mogi.start_time.minute) % 60, True) + return + for old_mogi in self.old_events: + await self.add_teams_to_rooms(old_mogi, (old_mogi.start_time.minute) % 60, True) + return -async def setup(bot): +async def setup(bot: commands.Bot): await bot.add_cog(SquadQueue(bot)) diff --git a/common.py b/common.py new file mode 100644 index 0000000..1c8ee9b --- /dev/null +++ b/common.py @@ -0,0 +1,34 @@ +import json +from enum import Enum, auto +from typing import TypeVar, Type, List + +_T = TypeVar('T') + +with open('./config.json', 'r') as cjson: + CONFIG = json.load(cjson) + + +class Server(Enum): + MKW = auto() + MK8DX = auto() + + +if CONFIG["lounge"] == "MKW": + SERVER = Server.MKW +elif CONFIG["lounge"] == "MK8DX": + SERVER = Server.MK8DX +else: + raise ValueError(f"{CONFIG['lounge']} is not a valid option for the 'lounge' attribute in the config.") + + +def divide_chunks(list_: list, chunk_size: int): + # looping till length l + for i in range(0, len(list_), chunk_size): + yield list_[i:i + chunk_size] + + +def flatten(matrix: List[List[_T]]) -> List[_T]: + flat_list = [] + for row in matrix: + flat_list.extend(row) + return flat_list diff --git a/example_configs/prod/mk8dx_prod_config.json b/example_configs/prod/mk8dx_prod_config.json new file mode 100644 index 0000000..9430b3c --- /dev/null +++ b/example_configs/prod/mk8dx_prod_config.json @@ -0,0 +1,47 @@ +{ + "token": "", + "is_production": true, + "lounge": "MK8DX", + "lounge_description": "Valid options are MKW or MK8DX", + "guild_id": 445404006177570829, + "queue_join_channel": 1186158671525318719, + "queue_sub_channel": 1186158767029620766, + "queue_list_channel": 1221665101904478340, + "queue_history_channel": 1194440106266865684, + "queue_general_channel": 1186158671525318719, + "admin_roles": [ + 464610815299944448, + 910528103225389107 + ], + "helper_staff_roles": [ + 910528103225389107 + ], + "members_for_channels": [ + 934740707934081035, + 743532508343304242, + 735910635082219570 + ], + "roles_for_channels": [], + "PLACEMENT_PLAYER_MMR": 1500, + "placement_role_id": 730980761322389504, + "frequently_tagged_role_id": 682445864400453739, + "restricted_role_id": 600495108999086090, + "muted_role_id": 600495108999086090, + "queue_messages": true, + "sec_between_queue_msgs": 2, + "username": "username", + "password": "password", + "url": "https://www.mk8dx-lounge.com", + "track_type": "rt", + "FIRST_EVENT_TIME": 0, + "FIRST_EVENT_TIME_DESCRIPTION": "First event time is the number of minutes from 0:00 UTC that the bot would run the first event of the day. As an example, if this were set to 5, and the QUEUE_OPEN_TIME were set to 70, and the bot were to reboot at 2:00 UTC, the next event would immediately start and create rooms at 2:25 UTC.", + "QUEUE_OPEN_TIME": 60, + "JOINING_TIME": 55, + "DISPLAY_OFFSET_MINUTES": 5, + "EXTENSION_TIME": 3, + "ROOM_JOIN_PENALTY_TIME": 5, + "MOGI_LIFETIME": 180, + "SUB_RANGE_MMR_ALLOWANCE": 500, + "SUB_MESSAGE_LIFETIME_SECONDS": 1200, + "ROOM_MMR_THRESHOLD": 9999999 +} \ No newline at end of file diff --git a/example_configs/prod/mkw_ct_prod_config.json b/example_configs/prod/mkw_ct_prod_config.json new file mode 100644 index 0000000..27d17c4 --- /dev/null +++ b/example_configs/prod/mkw_ct_prod_config.json @@ -0,0 +1,58 @@ +{ + "token": "", + "is_production": true, + "lounge": "MKW", + "lounge_description": "Valid options are MKW or MK8DX", + "guild_id": 387347467332485122, + "queue_join_channel": 1253109700107567155, + "queue_sub_channel": 1253109752418930779, + "queue_list_channel": 1253109466027655338, + "queue_history_channel": 1253109800196243618, + "queue_general_channel": 1253109398193045534, + "admin_roles": [ + 387347888935534593, + 877629421811540016 + ], + "helper_staff_roles": [ + 520808674411937792, + 792891432301625364, + 521149807994208295 + ], + "members_for_channels": [ + 235148962103951360, + 155149108183695360, + 815483289515196416, + 892420397570592768, + 775415990238314518, + 774812906830299136, + 745443073202323586, + 774866940950872095, + 1019051989734273054 + ], + "roles_for_channels": [ + 520808645252874240, + 520808674411937792 + ], + "PLACEMENT_PLAYER_MMR": 750, + "placement_role_id": 723753340063842345, + "frequently_tagged_role_id": 1167985222533533817, + "restricted_role_id": 797208908153618452, + "muted_role_id": 434887701662007296, + "queue_messages": true, + "sec_between_queue_msgs": 2, + "username": "username", + "password": "password", + "url": "https://www.mkwlounge.gg", + "track_type": "ct", + "FIRST_EVENT_TIME": 0, + "FIRST_EVENT_TIME_DESCRIPTION": "First event time is the number of minutes from 0:00 UTC that the bot would run the first event of the day. As an example, if this were set to 5, and the QUEUE_OPEN_TIME were set to 70, and the bot were to reboot at 2:00 UTC, the next event would immediately start and create rooms at 2:25 UTC.", + "QUEUE_OPEN_TIME": 90, + "JOINING_TIME": 85, + "DISPLAY_OFFSET_MINUTES": 0, + "EXTENSION_TIME": 5, + "ROOM_JOIN_PENALTY_TIME": 8, + "MOGI_LIFETIME": 180, + "SUB_RANGE_MMR_ALLOWANCE": 700, + "SUB_MESSAGE_LIFETIME_SECONDS": 1200, + "ROOM_MMR_THRESHOLD": 9000 +} \ No newline at end of file diff --git a/example_configs/prod/mkw_rt_prod_config.json b/example_configs/prod/mkw_rt_prod_config.json new file mode 100644 index 0000000..46515fe --- /dev/null +++ b/example_configs/prod/mkw_rt_prod_config.json @@ -0,0 +1,58 @@ +{ + "token": "", + "is_production": true, + "lounge": "MKW", + "lounge_description": "Valid options are MKW or MK8DX", + "guild_id": 387347467332485122, + "queue_join_channel": 1215174119767932979, + "queue_sub_channel": 1215174160234315806, + "queue_list_channel": 1215174057453162496, + "queue_history_channel": 1225709377747685477, + "queue_general_channel": 1241842559429513361, + "admin_roles": [ + 387347888935534593, + 877629421811540016 + ], + "helper_staff_roles": [ + 389252697284542465, + 399384750923579392, + 399382503825211393 + ], + "members_for_channels": [ + 235148962103951360, + 155149108183695360, + 815483289515196416, + 892420397570592768, + 775415990238314518, + 774812906830299136, + 745443073202323586, + 774866940950872095, + 1019051989734273054 + ], + "roles_for_channels": [ + 393600567781621761, + 389252697284542465 + ], + "PLACEMENT_PLAYER_MMR": 750, + "placement_role_id": 723753340063842345, + "frequently_tagged_role_id": 1167985222533533817, + "restricted_role_id": 797208908153618452, + "muted_role_id": 434887701662007296, + "queue_messages": true, + "sec_between_queue_msgs": 2, + "username": "username", + "password": "password", + "url": "https://www.mkwlounge.gg", + "track_type": "rt", + "FIRST_EVENT_TIME": 0, + "FIRST_EVENT_TIME_DESCRIPTION": "First event time is the number of minutes from 0:00 UTC that the bot would run the first event of the day. As an example, if this were set to 5, and the QUEUE_OPEN_TIME were set to 70, and the bot were to reboot at 2:00 UTC, the next event would immediately start and create rooms at 2:25 UTC.", + "QUEUE_OPEN_TIME": 72, + "JOINING_TIME": 67, + "DISPLAY_OFFSET_MINUTES": 0, + "EXTENSION_TIME": 5, + "ROOM_JOIN_PENALTY_TIME": 8, + "MOGI_LIFETIME": 180, + "SUB_RANGE_MMR_ALLOWANCE": 700, + "SUB_MESSAGE_LIFETIME_SECONDS": 1200, + "ROOM_MMR_THRESHOLD": 8000 +} \ No newline at end of file diff --git a/host_fcs.py b/host_fcs.py new file mode 100644 index 0000000..a107ab2 --- /dev/null +++ b/host_fcs.py @@ -0,0 +1,43 @@ +import common +from typing import Dict +from collections.abc import Iterable +import aiohttp + +MKW_HOST_API_URL = f"https://mkwlounge.gg/api/hostfc.php?discord_guild_id={common.CONFIG['guild_id']}&discord_user_id=" + +SESSION = None + + +def create_new_session(): + global SESSION + SESSION = aiohttp.ClientSession() + + +async def _get_mk8dx_hosts(discord_ids: Iterable[str]) -> Dict[str, str]: + return {} + + +async def _get_mkw_hosts(discord_ids: Iterable[str]) -> Dict[str, str]: + request_url = MKW_HOST_API_URL + ",".join(discord_ids) + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5)) as session: + async with session.get(request_url) as r: + if r.status != 200: + print(f"hostfc endpoint returned status {r.status}") + return {} + results = await r.json() + if "status" not in results or results["status"] != "success": + print(f"hostfc endpoint returned unsuccessful results {results}") + return {} + host_mapping = {} + for item in results["results"]: + host_mapping[item["discord_user_id"]] = item["fc"] + return host_mapping + + +async def get_hosts(discord_ids: Iterable[str]) -> Dict[str, str]: + if common.SERVER is common.Server.MKW: + return await _get_mkw_hosts(discord_ids) + elif common.SERVER is common.Server.MK8DX: + return await _get_mk8dx_hosts(discord_ids) + else: + raise ValueError("Bad config settings for server.") diff --git a/lounge.py b/lounge.py index 5fe2f82..c9dd60e 100644 --- a/lounge.py +++ b/lounge.py @@ -1,3 +1,4 @@ +import common import discord from discord.ext import commands import json @@ -10,9 +11,7 @@ case_insensitive=True, intents=intents, help_command=None) initial_extensions = ['cogs.SquadQueue'] - -with open('./config.json', 'r') as cjson: - bot.config = json.load(cjson) +bot.config = common.CONFIG @bot.event @@ -50,6 +49,13 @@ async def on_command_error(ctx, error): raise error +@bot.tree.error +async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError): + if isinstance(error, discord.app_commands.errors.CommandOnCooldown): + await interaction.response.send_message(f'You are on cooldown. Try again in {round(error.retry_after)}s', ephemeral=True) + else: + raise error + @bot.event async def setup_hook(): for extension in initial_extensions: diff --git a/mmr.py b/mmr.py index 3e61701..f709a7a 100644 --- a/mmr.py +++ b/mmr.py @@ -1,11 +1,31 @@ import aiohttp import discord from mogi_objects import Player +import common headers = {'Content-type': 'application/json'} -async def mk8dx_150cc_mmr(url, members): +# class LoungeData: +# def __init__(self): +# self._data = None + +# async def lounge_api_full(self): +# async with aiohttp.ClientSession() as session: +# async with session.get("https://www.mk8dx-lounge.com/api/player/list") as response: +# if response.status == 200: +# _data_full = await response.json() +# self._data = [ +# player for player in _data_full['players'] if "discordId" in player] + +# def data(self): +# return self._data + + +# lounge_data = LoungeData() + + +async def mk8dx_mmr(url, members): base_url = url + '/api/player?' players = [] timeout = aiohttp.ClientTimeout(total=10) @@ -33,8 +53,45 @@ async def mk8dx_150cc_mmr(url, members): return players -async def get_mmr_from_discord_id(discord_id): - base_url = "https://www.mk8dx-lounge.com" + '/api/player?' +async def mkw_mmr(url, members, track_type): + tracks = track_type + base_url = url + '/api/ladderplayer.php?ladder_type=' + tracks + '&' + players = [] + timeout = aiohttp.ClientTimeout(total=10) + try: + async with aiohttp.ClientSession( + timeout=timeout, + auth=aiohttp.BasicAuth( + "username", "password")) as session: + for member in members: + request_text = f"discord_user_id={member.id}" + request_url = base_url + request_text + async with session.get(request_url, headers=headers) as resp: + if resp.status != 200: + players.append(None) + continue + result = await resp.json() + player_data = result["results"][0] + if 'current_mmr' not in player_data.keys(): + players.append( + Player(member, player_data['player_name'], None)) + continue + players.append( + Player(member, player_data['player_name'], player_data['current_mmr'])) + except: + print(f"Fetch for player {members[0]} has failed.", flush=True) + return players + + +async def get_mmr(url, members, track_type): + if common.SERVER is common.Server.MK8DX: + return await mk8dx_mmr(url, members) + elif common.SERVER is common.Server.MKW: + return await mkw_mmr(url, members, track_type) + + +async def mk8dx_get_mmr_from_discord_id(url, discord_id): + base_url = url + '/api/player?' timeout = aiohttp.ClientTimeout(total=10) async with aiohttp.ClientSession( timeout=timeout, @@ -51,19 +108,29 @@ async def get_mmr_from_discord_id(discord_id): return player_data['mmr'] -async def get_mmr(config, members): - return await mk8dx_150cc_mmr(config, members) - - -async def mk8dx_150cc_fc(config, name): - base_url = config["url"] + '/api/player?' - request_url = base_url + f'name={name}' - timeout = aiohttp.ClientTimeout(total=15) - async with aiohttp.ClientSession(timeout=timeout) as session: +async def mkw_get_mmr_from_discord_id(url, discord_id, track_type): + tracks = track_type + base_url = url + \ + '/api/ladderplayer.php?ladder_type=' + tracks + '&' + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession( + timeout=timeout, + auth=aiohttp.BasicAuth( + "username", "password")) as session: + request_text = f"discord_user_id={discord_id}" + request_url = base_url + request_text async with session.get(request_url, headers=headers) as resp: if resp.status != 200: - return None - player_data = await resp.json() - if 'switchFc' not in player_data.keys(): - return None - return player_data['switchFc'] + return "Player does not exist" + result = await resp.json() + player_data = result["results"][0] + if 'current_mmr' not in player_data.keys(): + return "Player has no mmr" + return player_data['current_mmr'] + + +async def get_mmr_from_discord_id(url, discord_id, track_type): + if common.SERVER is common.Server.MK8DX: + return await mk8dx_get_mmr_from_discord_id(url, discord_id) + elif common.SERVER is common.Server.MKW: + return await mkw_get_mmr_from_discord_id(url, discord_id, track_type) diff --git a/mogi_objects.py b/mogi_objects.py index 7a9c1a8..dba908f 100644 --- a/mogi_objects.py +++ b/mogi_objects.py @@ -1,24 +1,127 @@ +from __future__ import annotations + +import common +from common import flatten import random import discord +from datetime import datetime, timezone, timedelta from discord.ui import View +from typing import List, Tuple, Callable +import host_fcs + + +def average(list_: List[int | float]) -> float: + return sum(list_) / len(list_) class Mogi: - def __init__(self, sq_id: int, size: int, mogi_channel: discord.TextChannel, - is_automated=False, start_time=None): + ALGORITHM_STATUS_INSUFFICIENT_PLAYERS = 1 + ALGORITHM_STATUS_2_OR_MORE_ROOMS = 2 + ALGORITHM_STATUS_SUCCESS_FOUND = 3 + ALGORITHM_STATUS_SUCCESS_EMPTY = 4 + + def __init__(self, sq_id: int, max_players_per_team: int, players_per_room: int, mogi_channel: discord.TextChannel, + is_automated=False, start_time=None, display_time=None, additional_extension_minutes=0): self.started = False self.gathering = False self.making_rooms_run = False + self.making_rooms_run_time = None self.sq_id = sq_id - self.size = size + self.max_player_per_team = max_players_per_team + self.players_per_room = players_per_room self.mogi_channel = mogi_channel - self.teams = [] - self.rooms = [] + self.teams: List[Team] = [] + self.rooms: List[Room] = [] self.is_automated = is_automated - if not is_automated: - self.start_time = None + self.start_time = start_time if is_automated else None + self.display_time = display_time if is_automated else None + self.additional_extension = timedelta( + minutes=additional_extension_minutes) + + @property + def num_players(self): + """Returns the total number of players in teams where all players have confirmed""" + return sum(len(t.players) for t in self.teams if t.all_registered()) + + @property + def num_teams(self): + """Returns the total number of teams where all players have confirmed""" + return self.count_registered() + + @property + def max_possible_rooms(self) -> int: + """Returns the maximum possible number of rooms based on teams where all players have confirmed. Depending on range cutoffs or + the specifics of the algorithm used to make the actual rooms, this number could be higher than the final number of rooms.""" + return self.num_players // self.players_per_room + + @staticmethod + def _minimize_range(players: List[Player], num_players: int) -> List[Player] | None: + """Returns a collection of players (the number of players in the collection is the given num_players parameter) whose has the smallest + mmr spread. If the number of players in the given list is smaller than the request num_players collection size, or the num_players is less than 2 (doesn't make sense), None is returned.""" + # The number of players we were given is less than the collection size we are supposed to return, so return None + if len(players) < num_players: + return None + if num_players <= 1: + return None + # Sort the players so we easily know the player with the lowest rating and highest rating in any given collection + sorted_players = sorted(players) + # In the beginning, the best found collection of players is the first 12 + best_collection = sorted_players[0:num_players] + cur_min = best_collection[-1].mmr - best_collection[0].mmr + # Find the collection of players with the least rating spread + for lowest_player_index, highest_player in enumerate(sorted_players[num_players:], 1): + lowest_player = sorted_players[lowest_player_index] + cur_range = highest_player.mmr - lowest_player.mmr + if cur_range < cur_min: + cur_min = cur_range + best_collection = sorted_players[lowest_player_index: + lowest_player_index + num_players] + return best_collection + + def _one_room_final_list_algorithm(self, valid_players_check: Callable[[List[Player]], bool]) -> Tuple[List[Player], int]: + if self.max_possible_rooms == 0: + return [], Mogi.ALGORITHM_STATUS_INSUFFICIENT_PLAYERS + if self.max_possible_rooms > 1: + confirmed_players = self.players_on_confirmed_teams() + return confirmed_players[0:self.players_per_room * self.max_possible_rooms], Mogi.ALGORITHM_STATUS_2_OR_MORE_ROOMS + # At this point, we can only make one possible room, so our algorithm will be used + confirmed_players = self.players_on_confirmed_teams() + cur_check_list = list(confirmed_players[0:self.players_per_room]) + late_players = list(confirmed_players[self.players_per_room:]) + + while True: + best_collection = Mogi._minimize_range( + cur_check_list, self.players_per_room) + if valid_players_check(best_collection): + return best_collection, Mogi.ALGORITHM_STATUS_SUCCESS_FOUND + if len(late_players) == 0: + break + cur_check_list.append(late_players.pop(0)) + # Even after checking the late players, we did not find + return [], Mogi.ALGORITHM_STATUS_SUCCESS_EMPTY + + def _mk8dx_generate_final_list(self) -> List[Player]: + confirmed_players = self.players_on_confirmed_teams() + return confirmed_players[0:self.players_per_room * self.max_possible_rooms] + + def _mkw_generate_final_list(self, valid_players_check: Callable[[List[Player]], bool]) -> List[Player]: + result, _ = self._one_room_final_list_algorithm(valid_players_check) + return result + + def generate_proposed_list(self, valid_players_check: Callable[[List[Player]], bool] = None) -> List[Player]: + """Algorithm that generates a proposed list of players that will play. This algorithm may differ between + MK8DX and MKW. The algorithm is allowed to propose any list of players it wants to. Among several possibilities, + this allows the algorithm to change the order of the players in the returned list, add or remove players, + and more. + + The algorithm may or may not enforce a hard check of the valid players. That is up to the implemented + algorithm.""" + if common.SERVER is common.Server.MK8DX: + return self._mk8dx_generate_final_list() + elif common.SERVER is common.Server.MKW: + return self._mkw_generate_final_list(valid_players_check) else: - self.start_time = start_time + raise ValueError(f"Unknown server in config: {common.Server}") def check_player(self, member): for team in self.teams: @@ -26,27 +129,15 @@ def check_player(self, member): return team return None - def count_registered(self): - count = 0 - for team in self.teams: - if team.is_registered(): - count += 1 - return count + def count_registered(self) -> int: + """Returns the number of teams that are registered""" + return sum(1 for team in self.teams if team.all_registered()) - def confirmed_list(self): - confirmed = [] - for team in self.teams: - if team.is_registered(): - confirmed.append(team) - return confirmed + def confirmed_teams(self) -> List["Team"]: + return [team for team in self.teams if team.all_registered()] - def remove_id(self, squad_id: int): - confirmed = self.confirmed_list() - if squad_id < 1 or squad_id > len(confirmed): - return None - squad = confirmed[squad_id-1] - self.teams.remove(squad) - return squad + def players_on_confirmed_teams(self) -> List[Player]: + return flatten([team.players for team in self.confirmed_teams()]) def is_room_thread(self, channel_id: int): for room in self.rooms: @@ -60,77 +151,112 @@ def get_room_from_thread(self, channel_id: int): return room return None + async def populate_host_fcs(self): + all_hosts = {str(plr.member.id): plr for plr in filter( + lambda p: p.host, self.players_on_confirmed_teams())} + hosts = await host_fcs.get_hosts(all_hosts) + for host_discord_id, host_fc in hosts.items(): + player: Player = all_hosts.get(host_discord_id) + if player is not None: + player.host_fc = host_fc + class Room: def __init__(self, teams, room_num: int, thread: discord.Thread): - self.teams = teams + self.teams: List["Team"] = teams self.room_num = room_num self.thread = thread - self.mmr_average = 0 - self.mmr_high = None - self.mmr_low = None self.view = None self.finished = False + self.host_list: List["Player"] = [] + self.subs: List["Player"] = [] + + @property + def mmr_high(self) -> int: + if self.teams is None: + return None + return max(self.players).mmr + + @property + def mmr_low(self) -> int: + if self.teams is None: + return None + return min(self.players).mmr + + @property + def avg_mmr(self) -> float: + if self.teams is None: + return 0 + return average([p.mmr for p in self.players]) + + @property + def players(self) -> List[Player]: + if self.teams is None: + return [] + return flatten([t.players for t in self.teams]) + + def get_player_list(self): + return [player.member.id for team in self.teams for player in team.players] + + def create_host_list(self): + all_hosts = list(filter(lambda p: p.host, self.players)) + random.shuffle(all_hosts) + self.host_list.clear() + self.host_list.extend(all_hosts) + + def get_host_str(self) -> str: + if len(self.host_list) == 0: + return "" + host_strs = [] + for i, player in enumerate(self.host_list, 1): + host_strs.append(f"{i}. {player.member.display_name}") + # First player on the list should be bold + if i == 1: + host_strs[0] = f"**{host_strs[0]}**" + result = f"Host: {', '.join(host_strs)}" + if common.SERVER is common.Server.MKW and self.host_list[0].host_fc is not None: + result += f"\n**Host ({self.host_list[0].member.display_name}) Friend Code: {self.host_list[0].host_fc}**" + return result class Team: - def __init__(self, players): + def __init__(self, players: List["Player"]): self.players = players - self.avg_mmr = sum([p.mmr for p in self.players]) / len(self.players) - def recalc_avg(self): - self.avg_mmr = sum([p.mmr for p in self.players]) / len(self.players) + def get_mentions(self): + """Return a string where all players on the team are discord @'d""" + return " ".join([p.member.mention for p in self.players]) - def is_registered(self): - for player in self.players: - if player.confirmed is False: - return False - return True + @property + def avg_mmr(self): + return average([p.mmr for p in self.players]) + + def all_registered(self): + """Returns if all players on the team are registered""" + return all(player.confirmed for player in self.players) def has_player(self, member): - for player in self.players: - if player.member.id == member.id: - return True - return False + return any(player.member.id == member.id for player in self.players) - def get_player(self, member): + def get_player(self, member: discord.Member) -> Player | None: for player in self.players: if player.member.id == member.id: return player return None - def get_first_player(self): - return self.players[0] - - def sub_player(self, sub_out, sub_in): - for i, player in enumerate(self.players): - if player == sub_out: - self.players[i] = sub_in - self.recalc_avg() - return - def num_confirmed(self): - count = 0 - for player in self.players: - if player.confirmed: - count += 1 - return count + """Returns the number of confirmed players in the team""" + return sum(1 for player in self.players if player.confirmed) def get_unconfirmed(self): - unconfirmed = [] - for player in self.players: - if not player.confirmed: - unconfirmed.append(player) - return unconfirmed + """Returns a list of players on the team who have not confirmed yet.""" + return [player for player in self.players if not player.confirmed] def __lt__(self, other): - if self.avg_mmr < other.avg_mmr: - return True - if self.avg_mmr > other.avg_mmr: - return False + return self.avg_mmr < other.avg_mmr def __gt__(self, other): - return other.__lt__(self) + return other < self # def __eq__(self, other): # if self.avg_mmr == other.avg_mmr: @@ -142,53 +268,90 @@ def __str__(self): class Player: - def __init__(self, member, lounge_name, mmr): + def __init__(self, member: discord.Member, lounge_name: str, mmr: int, confirmed=False, host=False): self.member = member self.lounge_name = lounge_name self.mmr = mmr - self.confirmed = False + self.confirmed = confirmed self.score = 0 + self.host = host + self.host_fc = None + + @property + def mention(self): + """String that, when sent in a Discord message, will mention and ping the player.""" + if self.member is None: + return "<@!1>" + return self.member.mention + + def __repr__(self): + return f"{__class__.__name__}(member={self.member}, lounge_name={self.lounge_name}, mmr={self.mmr}, confirmed={self.confirmed})" + + def __lt__(self, other: Player): + return self.mmr < other.mmr class VoteView(View): - def __init__(self, players, thread, mogi): + def __init__(self, players, thread, mogi: Mogi, room: Room, penalty_time: int, tier_info): super().__init__() self.players = players - self.thread = thread + self.thread: discord.Thread = thread self.mogi = mogi + self.room = room self.header_text = "" self.teams_text = "" self.found_winner = False - self.__setattr__("FFA", []) - self.__setattr__("2v2", []) - self.__setattr__("3v3", []) - self.__setattr__("4v4", []) - - def __getitem__(self, key): - return getattr(self, key) - - async def make_teams(self, format): + self.penalty_time = penalty_time + self.tier_info = tier_info + self.votes = {"FFA": [], + "2v2": [], + "3v3": [], + "4v4": [], + "6v6": [] + } + + self.add_button("FFA", self.general_vote_callback) + self.add_button("2v2", self.general_vote_callback) + self.add_button("3v3", self.general_vote_callback) + self.add_button("4v4", self.general_vote_callback) + + if common.SERVER is common.Server.MKW: + self.add_button("6v6", self.general_vote_callback) + + async def make_teams(self, format_): + if common.SERVER is common.Server.MKW: + self.mogi.making_rooms_run_time = datetime.now(timezone.utc) + elif common.SERVER is common.Server.MK8DX: + self.mogi.making_rooms_run_time = self.mogi.start_time + \ + timedelta(minutes=5) random.shuffle(self.players) room = self.mogi.get_room_from_thread(self.thread.id) - msg = "**Poll Ended!** \n\n" - msg += f"1) FFA - {len(self['FFA'])}\n" - msg += f"2) 2v2 - {len(self['2v2'])}\n" - msg += f"3) 3v3 - {len(self['3v3'])}\n" - msg += f"4) 4v4 - {len(self['4v4'])}\n" - msg += f"Winner: {format[1]}\n\n" + msg = f"""**Poll Ended!** + +1) FFA - {len(self.votes['FFA'])} +2) 2v2 - {len(self.votes['2v2'])} +3) 3v3 - {len(self.votes['3v3'])} +4) 4v4 - {len(self.votes['4v4'])} +""" + if common.SERVER is common.Server.MKW: + msg += f"5) 6v6 - {len(self.votes['6v6'])}\n" + msg += f"Winner: {format_[1]}\n\n" room_mmr = round(sum([p.mmr for p in self.players]) / 12) room.mmr_average = room_mmr - self.header_text = f"**Room {room.room_num} MMR: {room_mmr} - Tier {get_tier(room_mmr - 500)}** " - msg += self.header_text - msg += "\n" + self.header_text = "" + if common.SERVER is common.Server.MKW: + self.header_text += f"**Room {room.room_num} MMR: {room_mmr} - T{get_tier(room_mmr, self.tier_info)}** " + elif common.SERVER is common.Server.MK8DX: + self.header_text += f"**Room {room.room_num} MMR: {room_mmr} - Tier {get_tier_mk8dx(room_mmr - 500)}** " + msg += self.header_text + "\n" teams = [] - teams_per_room = int(12 / format[0]) + teams_per_room = int(12 / format_[0]) for j in range(teams_per_room): - team = Team(self.players[j*format[0]:(j+1)*format[0]]) + team = Team(self.players[j * format_[0]:(j + 1) * format_[0]]) teams.append(team) teams.sort(key=lambda team: team.avg_mmr, reverse=True) @@ -196,7 +359,7 @@ async def make_teams(self, format): scoreboard_text = [] for j in range(teams_per_room): - team_text = f"`{j+1}.` " + team_text = f"`{j + 1}.` " team_names = ", ".join([p.lounge_name for p in teams[j].players]) scoreboard_text.append(team_names) team_text += team_names @@ -204,40 +367,50 @@ async def make_teams(self, format): msg += team_text self.teams_text += team_text - msg += f"\nTable: `/scoreboard`\n" + if common.SERVER is common.Server.MK8DX: + msg += f"\nTable: `/scoreboard`\n" - msg += f"RandomBot Scoreboard: `/scoreboard {teams_per_room} {', '.join(scoreboard_text)}`\n\n" + msg += f"RandomBot Scoreboard: `/scoreboard {teams_per_room} {', '.join(scoreboard_text)}`\n\n" - msg += "Decide a host amongst yourselves; room open at :00, penalty at :06. Good luck!" + penalty_time = self.mogi.making_rooms_run_time + \ + timedelta(minutes=self.penalty_time) + room_open_time = self.mogi.making_rooms_run_time + potential_host_str = self.room.get_host_str() + if potential_host_str == "": + msg += f"\nDecide a host amongst yourselves; room open at :{room_open_time.minute:02}, penalty at :{penalty_time.minute:02}. Good luck!" + else: + msg += f"{potential_host_str}\n\nRoom open at :{room_open_time.minute:02}, penalty at :{penalty_time.minute:02}. Good luck!" room.teams = teams self.found_winner = True + msg = msg.replace("_", "\_") await self.thread.send(msg) + if common.SERVER is common.Server.MKW: + new_thread_name = self.thread.name + \ + f" - T{get_tier(room_mmr, self.tier_info)}" + await self.thread.edit(name=new_thread_name) + elif common.SERVER is common.Server.MK8DX: + new_thread_name = self.thread.name + \ + f" - Tier {get_tier_mk8dx(room_mmr - 500)}" + await self.thread.edit(name=new_thread_name) async def find_winner(self): if not self.found_winner: - # for some reason max function wasnt working... - max = 0 - if len(self["FFA"]) > max: - max = len(self["FFA"]) - if len(self["2v2"]) > max: - max = len(self["2v2"]) - if len(self["3v3"]) > max: - max = len(self["3v3"]) - if len(self["4v4"]) > max: - max = len(self["4v4"]) + # for some reason max function wasnt working... # It's because you were replacing it. + most_votes = len(max(self.votes.values(), key=len)) winners = [] - - if len(self["FFA"]) == max: + if len(self.votes["FFA"]) == most_votes: winners.append((1, "FFA")) - if len(self["2v2"]) == max: + if len(self.votes["2v2"]) == most_votes: winners.append((2, "2v2")) - if len(self["3v3"]) == max: + if len(self.votes["3v3"]) == most_votes: winners.append((3, "3v3")) - if len(self["4v4"]) == max: + if len(self.votes["4v4"]) == most_votes: winners.append((4, "4v4")) + if common.SERVER is common.Server.MKW and len(self.votes["6v6"]) == most_votes: + winners.append((6, "6v6")) winner = random.choice(winners) @@ -246,104 +419,53 @@ async def find_winner(self): await self.make_teams(winner) - @discord.ui.button(label="FFA: 0", custom_id="FFA") - async def one_button_callback(self, interaction, button): - if not self.found_winner: - if interaction.user.id in self["FFA"]: - self["FFA"].remove(interaction.user.id) - else: - if interaction.user.id in self["2v2"]: - self["2v2"].remove(interaction.user.id) - if interaction.user.id in self["3v3"]: - self["3v3"].remove(interaction.user.id) - if interaction.user.id in self["4v4"]: - self["4v4"].remove(interaction.user.id) - self["FFA"].append(interaction.user.id) - if len(self["FFA"]) == 6: - await self.make_teams((1, "FFA")) - for curr_button in self.children: - curr_button.label = f"{curr_button.custom_id}: {len(self[curr_button.custom_id])}" - if len(self["FFA"]) == 6: - curr_button.disabled = True - await interaction.response.edit_message(view=self) + def add_button(self, label, callback): + button = discord.ui.Button(label=f"{label}: 0", custom_id=label) + button.callback = callback + self.add_item(button) - @discord.ui.button(label="2v2: 0", custom_id="2v2") - async def two_button_callback(self, interaction, button): + async def general_vote_callback(self, interaction: discord.Interaction): if not self.found_winner: - if interaction.user.id in self["2v2"]: - self["2v2"].remove(interaction.user.id) - else: - if interaction.user.id in self["FFA"]: - self["FFA"].remove(interaction.user.id) - if interaction.user.id in self["3v3"]: - self["3v3"].remove(interaction.user.id) - if interaction.user.id in self["4v4"]: - self["4v4"].remove(interaction.user.id) - self["2v2"].append(interaction.user.id) - if len(self["2v2"]) == 6: - await self.make_teams((2, "2v2")) + vote = interaction.data['custom_id'] + players_per_team = 1 + if vote != "FFA": + players_per_team = int(vote[0]) + original_vote = None + for vote_option, voter_ids in self.votes.items(): + if interaction.user.id in voter_ids: + original_vote = vote_option + voter_ids.remove(interaction.user.id) + if original_vote != vote: # They changed their vote or are a new voter + self.votes[vote].append(interaction.user.id) + if len(self.votes[vote]) == 6: + self.found_winner = True # This fixes a race condition + await self.make_teams((players_per_team, vote)) for curr_button in self.children: - curr_button.label = f"{curr_button.custom_id}: {len(self[curr_button.custom_id])}" - if len(self["2v2"]) == 6: - curr_button.disabled = True - await interaction.response.edit_message(view=self) - - @discord.ui.button(label="3v3: 0", custom_id="3v3") - async def three_button_callback(self, interaction, button): - if not self.found_winner: - if interaction.user.id in self["3v3"]: - self["3v3"].remove(interaction.user.id) - else: - if interaction.user.id in self["FFA"]: - self["FFA"].remove(interaction.user.id) - if interaction.user.id in self["2v2"]: - self["2v2"].remove(interaction.user.id) - if interaction.user.id in self["4v4"]: - self["4v4"].remove(interaction.user.id) - self["3v3"].append(interaction.user.id) - if len(self["3v3"]) == 6: - await self.make_teams((3, "3v3")) - for curr_button in self.children: - curr_button.label = f"{curr_button.custom_id}: {len(self[curr_button.custom_id])}" - if len(self["3v3"]) == 6: - curr_button.disabled = True - await interaction.response.edit_message(view=self) - - @discord.ui.button(label="4v4: 0", custom_id="4v4") - async def four_button_callback(self, interaction, button): - if not self.found_winner: - if interaction.user.id in self["4v4"]: - self["4v4"].remove(interaction.user.id) - else: - if interaction.user.id in self["FFA"]: - self["FFA"].remove(interaction.user.id) - if interaction.user.id in self["2v2"]: - self["2v2"].remove(interaction.user.id) - if interaction.user.id in self["3v3"]: - self["3v3"].remove(interaction.user.id) - self["4v4"].append(interaction.user.id) - if len(self["4v4"]) == 6: - await self.make_teams((4, "4v4")) - for curr_button in self.children: - curr_button.label = f"{curr_button.custom_id}: {len(self[curr_button.custom_id])}" - if len(self["4v4"]) == 6: + curr_button.label = f"{curr_button.custom_id}: {len(self.votes[curr_button.custom_id])}" + if self.found_winner: curr_button.disabled = True await interaction.response.edit_message(view=self) class JoinView(View): - def __init__(self, room, get_mmr): + def __init__(self, room: Room, get_mmr, sub_range_mmr_allowance, bottom_room_num, is_restricted: Callable[[discord.User | discord.Member], bool] | None = None): super().__init__(timeout=1200) self.room = room self.get_mmr = get_mmr + self.sub_range_mmr_allowance = sub_range_mmr_allowance + self.bottom_room_num = bottom_room_num + self.is_restricted = is_restricted @discord.ui.button(label="Join Room") - async def button_callback(self, interaction, button): + async def button_callback(self, interaction: discord.Interaction, button): await interaction.response.defer() - muted_role_id = 600495108999086090 - if interaction.user.get_role(muted_role_id): + if self.is_restricted is not None and self.is_restricted(interaction.user): await interaction.followup.send( - "Players with the muted role cannot use the sub button.", ephemeral=True) + "Players with the muted or restricted role cannot use the sub button.", ephemeral=True) + return + if interaction.user.id in self.room.get_player_list() + self.room.subs: + await interaction.followup.send( + "You are already in this room.", ephemeral=True) return try: user_mmr = await self.get_mmr(interaction.user.id) @@ -351,9 +473,15 @@ async def button_callback(self, interaction, button): await interaction.followup.send( "MMR lookup for player has failed, please try again.", ephemeral=True) return - if self.room.room_num == 1: - self.room.mmr_high = 999999 - if isinstance(user_mmr, int) and user_mmr < self.room.mmr_high + 500 and user_mmr > self.room.mmr_low - 500: + # Need a 2nd check to control the race condition introduced by "await self.get_mmr" + if interaction.user.id in self.room.get_player_list() + self.room.subs: + await interaction.followup.send( + "You are already in this room.", ephemeral=True) + return + mmr_high = 999999 if self.room.room_num == 1 else self.room.mmr_high + mmr_low = -999999 if self.room.room_num == self.bottom_room_num else self.room.mmr_low + if isinstance(user_mmr, int) and mmr_high + self.sub_range_mmr_allowance > user_mmr > mmr_low - self.sub_range_mmr_allowance: + self.room.subs.append(interaction.user.id) button.disabled = True await interaction.followup.edit_message(interaction.message.id, view=self) mention = interaction.user.mention @@ -363,7 +491,14 @@ async def button_callback(self, interaction, button): "You do not meet room requirements", ephemeral=True) -def get_tier(mmr: int): +def get_tier(mmr: int, tier_info): + for tier in tier_info: + if (tier["minimum_mmr"] is None or mmr >= tier["minimum_mmr"]) and ( + tier["maximum_mmr"] is None or mmr <= tier["maximum_mmr"]): + return tier["ladder_order"] + + +def get_tier_mk8dx(mmr: int): if mmr > 14000: return 'X' if mmr > 13000: diff --git a/redeploy.sh b/redeploy.sh index 9db4c40..4cf9181 100644 --- a/redeploy.sh +++ b/redeploy.sh @@ -1,5 +1,5 @@ #!/bin/bash -docker build --tag mogi-queuebot . -docker stop mogi-queuebot -docker rm mogi-queuebot -docker run -d --name mogi-queuebot --restart unless-stopped mogi-queuebot \ No newline at end of file +docker build --tag mkwii-mogi-queuebot . +docker stop mkwii-mogi-queuebot +docker rm mkwii-mogi-queuebot +docker run -d --name mkwii-mogi-queuebot --restart unless-stopped mkwii-mogi-queuebot \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5724417..bfbed65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ aiohttp==3.8.4 discord.py==2.3.2 python-dateutil==2.8.2 +dill~=0.3.8 diff --git a/sample_config.json b/sample_config.json deleted file mode 100644 index 6f8aaad..0000000 --- a/sample_config.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "token": "", - "application_id": 1183741625986465803, - "guild_id": 1139900310081507398, - - "admin_roles": {"1139900310081507398": ["Administrator", "Lounge Staff"]}, - "staff_roles": {"1139900310081507398": [910528103225389107]}, - - "queue_join_channel": 1139900310861660163, - "queue_sub_channel": 1186097268240162846, - "queue_history_channel": 1194143778156576880, - - "members_for_channels": {"1139900310081507398": [934740707934081035, 743532508343304242, 735910635082219570]}, - - "channel_category_position": 5, - "channels_per_category": 25, - - "queue_messages": true, - "sec_between_queue_msgs": 2, - - "username": "username", - "password": "password", - "url": "https://www.mk8dx-lounge.com", - - "TIME_ADJUSTMENT": 0, - "QUEUE_OPEN_TIME": 60, - "JOINING_TIME": 55, - "EXTENSION_TIME": 3, - "MOGI_LIFETIME": 180, - "SUB_MESSAGE_LIFETIME_SECONDS": 1200 -} \ No newline at end of file diff --git a/unit_testing.py b/unit_testing.py new file mode 100644 index 0000000..5c4f935 --- /dev/null +++ b/unit_testing.py @@ -0,0 +1,340 @@ +import unittest + +import common +from cogs import SquadQueue +from mogi_objects import Player, Mogi, Team +import random + + +class AlgorithmTests(unittest.TestCase): + + def test_empty_player_list(self): + """Test for empty player list""" + players = [] + num_players = 12 + result = Mogi._minimize_range(players, num_players) + self.assertIsNone(result) + + def test_not_enough_players(self): + """Test for when too many players are requested""" + players = [Player(None, None, None), Player(None, None, None)] + random.shuffle(players) + num_players = 3 + result = Mogi._minimize_range(players, num_players) + self.assertIsNone(result) + + def test_enough_players(self): + """Test for when there are enough requested players""" + players = [Player(None, "Joe", 100), Player(None, "Bob", 200)] + random.shuffle(players) + num_players = 2 + result = Mogi._minimize_range(players, num_players) + self.assertIsNotNone(result) + + def test_one_possible_collection(self): + """Test for when the number of players given is the number of players requested""" + p1 = Player(None, "Joe", 100) + p2 = Player(None, "Bob", 200) + players = [p1, p2] + random.shuffle(players) + num_players = 2 + result = Mogi._minimize_range(players, num_players) + self.assertListEqual([p1, p2], result) + + def test_correct_collection_1(self): + """Test #1 for correctness""" + p1 = Player(None, "Joe", 100) + p2 = Player(None, "Bob", 200) + p3 = Player(None, "Jane", 250) + players = [p1, p2, p3] + random.shuffle(players) + num_players = 2 + result = Mogi._minimize_range(players, num_players) + self.assertListEqual([p2, p3], result) + + def test_correct_collection_2(self): + """Test #2 for correctness""" + p1 = Player(None, "Joe", 100) + p2 = Player(None, "Bob", 200) + p3 = Player(None, "Jane", 350) + players = [p1, p2, p3] + random.shuffle(players) + num_players = 2 + result = Mogi._minimize_range(players, num_players) + self.assertListEqual([p1, p2], result) + + def test_correct_collection_3(self): + """Test #3 for correctness""" + p1 = Player(None, "Joe", 100) + p2 = Player(None, "Bob", 0) + p3 = Player(None, "Jane", 350) + players = [p1, p2, p3] + random.shuffle(players) + num_players = 2 + result = Mogi._minimize_range(players, num_players) + self.assertListEqual([p2, p1], result) + + def test_correct_collection_4(self): + """Test #4 for correctness""" + p1 = Player(None, "Joe", 0) + p2 = Player(None, "Bob", -100) + p3 = Player(None, "Jane", 1) + players = [p1, p2, p3] + random.shuffle(players) + num_players = 2 + result = Mogi._minimize_range(players, num_players) + self.assertListEqual([p1, p3], result) + + def test_correct_collection_5(self): + """Test #5 for correctness""" + p1 = Player(None, "Joe", 0) + p2 = Player(None, "Bob", 100) + p3 = Player(None, "Jane", 1) + players = [p1, p2, p3] + random.shuffle(players) + num_players = 2 + result = Mogi._minimize_range(players, num_players) + self.assertListEqual([p1, p3], result) + + def test_correct_collection_6(self): + """Test #6 for correctness, large player collection: + -300 + 0 + 100 + 200 + 500 + 501 + 502 + 600 + 750 + 800 + 1000 + 1500 : 1800 + 1501 : 1501 + 1550 : 1450 + 1800 : 1600 + 2000 : 1500""" + p1 = Player(None, "1", 1000) + p2 = Player(None, "2", 500) + p3 = Player(None, "3", 1500) + p4 = Player(None, "4", 750) + p5 = Player(None, "5", 800) + p6 = Player(None, "6", 2000) + p7 = Player(None, "7", 0) + p8 = Player(None, "8", -300) + p9 = Player(None, "9", 501) + p10 = Player(None, "10", 600) + p11 = Player(None, "11", 200) + p12 = Player(None, "12", 1550) + p13 = Player(None, "13", 1800) + p14 = Player(None, "14", 1501) + p15 = Player(None, "15", 502) + p16 = Player(None, "16", 100) + players = [p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15, p16] + random.shuffle(players) + num_players = 12 + result = Mogi._minimize_range(players, num_players) + self.assertListEqual([p16, p11, p2, p9, p15, p10, p4, p5, p1, p3, p14, p12], result) + + def test_correct_collection_7(self): + """Test #7 for large player collection where 12 players are given and 12 are requested""" + p1 = Player(None, "1", 1000) + p2 = Player(None, "2", 500) + p3 = Player(None, "3", 1500) + p4 = Player(None, "4", 750) + p5 = Player(None, "5", 800) + p6 = Player(None, "6", 2000) + p7 = Player(None, "7", 0) + p8 = Player(None, "8", -300) + p9 = Player(None, "9", 500) + p10 = Player(None, "10", 600) + p11 = Player(None, "11", 200) + p12 = Player(None, "12", 1550) + players = [p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12] + random.shuffle(players) + num_players = 12 + result = Mogi._minimize_range(players, num_players) + self.assertSetEqual(set(players), set(result)) + + +class OneRoomAlgorithmTests(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.threshold = 65 + cls.threshold_function = None + if common.SERVER is common.Server.MKW: + cls.threshold_function = lambda players: SquadQueue.mkw_players_allowed(players, cls.threshold) + elif common.SERVER is common.Server.MK8DX: + cls.threshold_function = lambda players: SquadQueue.mk8dx_players_allowed(players, cls.threshold) + else: + raise Exception("Lounge parameter in config is invalid, unit tests will fail.") + + def _new_default_mogi(self) -> Mogi: + """Returns a default mogi with 12 players per room.""" + self # to hide annoying PyCharm IDE error - well aware it could be static, do not want it to be + return Mogi(sq_id=1, + max_players_per_team=1, + players_per_room=12, + mogi_channel=None + ) + + def test_no_players(self): + """Test when no players are queued""" + mogi = self._new_default_mogi() + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertListEqual([], results) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_INSUFFICIENT_PLAYERS) + + def test_one_player(self): + """Test when one player is queued""" + p1 = Player(None, "1", 1000, True) + mogi = self._new_default_mogi() + mogi.teams.append(Team([p1])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertListEqual([], results) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_INSUFFICIENT_PLAYERS) + + def test_two_players(self): + """Test when two players are queued""" + p1 = Player(None, "1", 1000, True) + p2 = Player(None, "2", 2000, True) + mogi = self._new_default_mogi() + mogi.teams.append(Team([p1])) + mogi.teams.append(Team([p2])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertListEqual([], results) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_INSUFFICIENT_PLAYERS) + + def test_large_max_players_per_team(self): + """This is to catch the Mogi class computes the number of confirmed players incorrectly""" + p1 = Player(None, "1", 1000, True) + mogi = self._new_default_mogi() + mogi.max_player_per_team = 24 + mogi.teams.append(Team([p1])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertListEqual([], results) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_INSUFFICIENT_PLAYERS) + + def test_2_rooms_correctness_1(self): + """Test if 24P for 12 player rooms generates 2 rooms with correct status code""" + players = [Player(None, f"{i}", i, True) for i in range(24)] + mogi = self._new_default_mogi() + for p in players: + mogi.teams.append(Team([p])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertSetEqual(set(players), set(results)) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_2_OR_MORE_ROOMS) + + def test_2_rooms_correctness_2(self): + """Test if 25P for 12 player rooms generates 2 rooms with correct players and correct status code""" + players = [Player(None, f"{i}", i, True) for i in range(25)] + random.shuffle(players) + mogi = self._new_default_mogi() + for p in players: + mogi.teams.append(Team([p])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertSetEqual(set(players[0:24]), set(results)) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_2_OR_MORE_ROOMS) + + def test_2_rooms_correctness_3(self): + """Test if 25P for 12 player rooms generates 2 rooms with correct players and correct status code even with extremely large range""" + players = [Player(None, f"{i*10000}", i*10000, True) for i in range(25)] + random.shuffle(players) + mogi = self._new_default_mogi() + for p in players: + mogi.teams.append(Team([p])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertSetEqual(set(players[0:24]), set(results)) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_2_OR_MORE_ROOMS) + + def test_1_room_correctness_1(self): + """Test if 15P for 12 player rooms generates 1 rooms with correct players and correct status code""" + assert self.threshold == 65 + players = [Player(None, f"{i}", abs(i-8)*(i-8), True) for i in range(17)] # -64, -49, -36, ... 0, 1, 4, ... 64 + players.pop(15) # Set 36 rating to not confirmed so only one possibility occurs + correct_result = players[2:14].copy() # Only one possibility that matches threshold + random.shuffle(players) + mogi = self._new_default_mogi() + for p in players: + mogi.teams.append(Team([p])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_SUCCESS_FOUND) + self.assertSetEqual(set(correct_result), set(results)) + + def test_2_room_correctness_2(self): + """Test if 15P for 12 player rooms does not find any rooms when players are beyond range""" + assert self.threshold == 65 + players = [Player(None, f"{i}", (i-8)**3, True) for i in range(17)] # -512, -343, ... 0, 1, 8, ... 512 + correct_result = [] # No possible list of players + random.shuffle(players) + mogi = self._new_default_mogi() + for p in players: + mogi.teams.append(Team([p])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_SUCCESS_EMPTY) + self.assertSetEqual(set(correct_result), set(results)) + + def test_2_room_correctness_3(self): + """Test if 15P for 12 player rooms finds the single correct solution""" + assert self.threshold == 65 + p1 = Player(None, "1", 10, True) + p2 = Player(None, "2", 5, True) + p3 = Player(None, "3", 14, True) + p4 = Player(None, "4", 75, True) + p5 = Player(None, "5", 8, True) + p6 = Player(None, "6", 20, True) + p7 = Player(None, "7", 0, True) + p8 = Player(None, "8", -3, True) + p9 = Player(None, "9", 4, True) + p10 = Player(None, "10", -105, True) + p11 = Player(None, "11", 2, True) + p12 = Player(None, "12", 15, True) + p13 = Player(None, "13", 16, True) + p14 = Player(None, "14", 74, True) + p15 = Player(None, "15", 9, True) + p16 = Player(None, "16", 100, True) + # p4 should be removed, p10 should be removed, p13 should be added, p15 should be added + players = [p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15, p16] + correct_result = [p1, p2, p3, p5, p6, p7, p8, p9, p11, p12, p13, p15] + mogi = self._new_default_mogi() + for p in players: + mogi.teams.append(Team([p])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_SUCCESS_FOUND) + self.assertSetEqual(set(correct_result), set(results)) + + def test_2_room_correctness_4(self): + """Test if 15P for 12 player rooms finds no solution""" + assert self.threshold == 65 + p1 = Player(None, "1", 10, True) + p2 = Player(None, "2", 5, True) + p3 = Player(None, "3", 14, True) + p4 = Player(None, "4", 75, True) + p5 = Player(None, "5", 8, True) + p6 = Player(None, "6", 20, True) + p7 = Player(None, "7", 0, True) + p8 = Player(None, "8", -3, True) + p9 = Player(None, "9", 4, True) + p10 = Player(None, "10", -105, True) + p11 = Player(None, "11", 2, True) + p12 = Player(None, "12", 15, True) + p13 = Player(None, "13", 16, True) + p14 = Player(None, "14", 74, True) + p15 = Player(None, "15", 9, False) + p16 = Player(None, "16", 100, True) + players = [p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15, p16] + correct_result = [] # Same as previous test, but since p15 is not confirmed, no result is found + mogi = self._new_default_mogi() + for p in players: + mogi.teams.append(Team([p])) + results, status_code = mogi._one_room_final_list_algorithm(OneRoomAlgorithmTests.threshold_function) + self.assertEqual(status_code, SquadQueue.Mogi.ALGORITHM_STATUS_SUCCESS_EMPTY) + self.assertSetEqual(set(correct_result), set(results)) + + + +if __name__ == "__main__": + """If PyCharm, add --verbose flag to configuration. + Other IDEs, run from shell: python -m unittest ./unit_testing.py --verbose""" + unittest.main(verbosity=2)