diff --git a/.gitignore b/.gitignore index 8c60208..a141a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ /.venv* /config.json /credentials.json +.idea/* +/example_configs/test/* +staff_settings.pkl diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..59d666b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.formatting.provider": "autopep8", + "python.formatting.autopep8Args": ["--aggressive", "--max-line-length=79"], + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a1e5c8 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +This branch lets the bot recyle using existing text channels rather than creating threads for each room. diff --git a/cogs/SquadQueue.py b/cogs/SquadQueue.py index 7abd1e9..7880ff5 100644 --- a/cogs/SquadQueue.py +++ b/cogs/SquadQueue.py @@ -1,31 +1,190 @@ +from __future__ import annotations + +import aiohttp import discord from discord.ext import commands, tasks from discord import app_commands -from dateutil.parser import parse +from discord.app_commands import Range from datetime import datetime, timezone, timedelta +import pytz 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 +import mogi_objects +from common import divide_chunks +import mmr +from mogi_objects import Mogi, Team, Player, Room, VoteView, JoinView import asyncio +from collections import defaultdict +from typing import Optional, 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) +STAFF_SETTINGS_PKL = "./settings_data/staff_settings.pkl" + + +def is_restricted(user: discord.User | discord.Member) -> bool: + muted_role_id = common.CONFIG.get("muted_role_id") + restricted_role_id = common.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].adjusted_mmr - + sorted_players[0].adjusted_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) + + +def mkworld_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) + + +@app_commands.guild_only() +class SettingsGroup(app_commands.Group): + def __init__(self, squad_queue: SquadQueue, *args, **kwargs): + super().__init__(*args, **kwargs) + self.squad_queue: SquadQueue = squad_queue + + @app_commands.guild_only() + @app_commands.command(name="mmr_threshold", + description="Change the maximum MMR gap allowed for a room.") + @app_commands.describe( + mmr_range="The maximum range allowed for a room. Rooms with a range larger than this will be cancelled.") + async def mmr_threshold(self, interaction: discord.Interaction, mmr_range: int): + self.squad_queue.room_mmr_threshold = mmr_range + self.squad_queue.dump_staff_settings() + await interaction.response.send_message(f"MMR Threshold for Queue Rooms has been modified to {mmr_range} MMR.") + + @app_commands.command(name="time_between_events", + description="Change the amount of time between each queue opening.") + @app_commands.describe(event_time="Amount of time between each queue's opening.") + @app_commands.guild_only() + async def time_between_events(self, interaction: discord.Interaction, event_time: int): + if event_time > 15: + self.squad_queue.QUEUE_OPEN_TIME = timedelta(minutes=event_time) + self.squad_queue.JOINING_TIME = timedelta( + minutes=event_time) - self.squad_queue.EXTENSION_TIME + self.squad_queue.dump_staff_settings() + 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.guild_only() + @app_commands.command( + name="low_mmr_floor", + description="Players below this rating will be considered this rating for matchmaking/sub purposes.") + @app_commands.describe( + rating="Players below this rating will be considered this rating for matchmaking/sub purposes.") + async def low_mmr_floor(self, interaction: discord.Interaction, rating: int): + common.CONFIG["MATCHMAKING_BOTTOM_MMR"] = rating + self.squad_queue.dump_staff_settings() + await interaction.response.send_message(f"Players below {rating} MMR will be considered {rating} MMR for matchmaking and subbing in purposes.") + + @app_commands.guild_only() + @app_commands.command( + name="high_mmr_ceiling", + description="Players above this rating will be considered this rating for matchmaking/sub purposes.") + @app_commands.describe( + rating="Players above this rating will be considered this rating for matchmaking/sub purposes.") + async def high_mmr_ceiling(self, interaction: discord.Interaction, rating: int): + common.CONFIG["MATCHMAKING_TOP_MMR"] = rating + self.squad_queue.dump_staff_settings() + await interaction.response.send_message(f"Players above {rating} MMR will be considered {rating} MMR for matchmaking and subbing in purposes.") + + @app_commands.guild_only() + @app_commands.command(name="first_event_open_time", + description="Use to shift schedule.") + @app_commands.describe( + minutes="The number of minutes AFTER 0:00 UTC that your first queue of the day would open.") + async def first_event_open_time(self, interaction: discord.Interaction, minutes: int): + old_amount = common.CONFIG["FIRST_EVENT_TIME"] + shift = minutes - old_amount + common.CONFIG["FIRST_EVENT_TIME"] = minutes + self.squad_queue.FIRST_EVENT_TIME = datetime.combine( + datetime.utcnow().date(), + datetime.min.time(), + tzinfo=timezone.utc) + timedelta( + minutes=common.CONFIG["FIRST_EVENT_TIME"]) + self.squad_queue.dump_staff_settings() + await interaction.response.send_message(f"Changed first queue of the day's open time to {minutes} minutes after 0:00 UTC, which will shift all events by {shift} minutes.") + + @app_commands.guild_only() + @app_commands.command( + name="sub_range_allowance", + description="Buffer from the lowest and highest player that someone's rating could be to be allowed to sub in.") + @app_commands.describe( + buffer="The buffer. E.g. 500 buffer, low mmr 2000, high mmr 6000, so allowed range 1500-6500") + async def sub_range_allowance(self, interaction: discord.Interaction, buffer: int): + self.squad_queue.SUB_RANGE_MMR_ALLOWANCE = buffer + self.squad_queue.dump_staff_settings() + await interaction.response.send_message(f"Players rated higher than {buffer} below a room's lowest player and lower than {buffer} above a room's highest player will be allowed to sub into the room.") + + @app_commands.guild_only() + @app_commands.command(name="placement_rating", + description="Rating for placement players when they queue.") + @app_commands.describe(rating="The placement players' rating.") + async def placement_rating(self, interaction: discord.Interaction, rating: int): + self.squad_queue.PLACEMENT_PLAYER_MMR = rating + common.CONFIG["PLACEMENT_PLAYER_MMR"] = rating + self.squad_queue.dump_staff_settings() + await interaction.response.send_message(f"Placement players' rating will be {rating} MMR when they queue.") + class SquadQueue(commands.Cog): def __init__(self, bot): - self.bot = bot + self.bot: commands.Bot = bot - # 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.bot_startup_time = datetime.now(timezone.utc) - self.old_events = {} + self.is_production = bot.config["is_production"] - self.sq_times = [] + self.next_event: Mogi = None + + self.ongoing_event: Mogi = None + + self.old_events: List[Mogi] = [] + + self.sq_times: List[datetime] = [] + + self.specific_player_amount_queue_times: List[Tuple[datetime, int]] = [ + ] + + self.forced_format_times: List[Tuple[datetime, str]] = [] + + self.forced_format_order: List[str] = bot.config["FORCED_FORMAT_ORDER"] + + self.queues_between_forced_format_queue: int | None = bot.config[ + "QUEUES_BETWEEN_FORCED_FORMAT_QUEUE"] + + # Parameters for tracking if we should send an extension message or not + self.last_extension_message_timestamp = datetime.now( + timezone.utc).replace(second=0, microsecond=0) + self.cur_extension_message = None - 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 +194,151 @@ def __init__(self, bot): self.list_messages = [] - self.QUEUE_TIME_BLOCKER = datetime.now(timezone.utc) + self.format_messages = [] - self.GUILD = None + self.LAUNCH_NEW_EVENTS = True + + self.GUILD: discord.Guild = None self.MOGI_CHANNEL = None self.SUB_CHANNEL = None + self.LIST_CHANNEL = None + self.HISTORY_CHANNEL = None - self.LOCK = asyncio.Lock() + self.SCHEDULE_CHANNEL = None + + self.GENERAL_CHANNEL: discord.TextChannel = None self.URL = bot.config["url"] + self.PLAYERS_PER_ROOM: int = self.bot.config.get( + "PLAYERS_PER_ROOM", 12) + + self.ALLOWED_VOTE_BUTTONS = [] + + 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"] + + 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"]) - # number of minutes after JOINING_TIME for any potential extra teams to join + 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"]) + + self.FORCED_FORMAT_FIRST_EVENT = datetime( + 2024, 8, 1, 0, 0, 0, tzinfo=timezone.utc) + timedelta(minutes=bot.config["FIRST_EVENT_TIME"]) \ + + timedelta(hours=bot.config["FORCED_FORMAT_HOURLY_OFFSET"]) + + self.FORCED_FORMAT_ORDER_OFFSET = bot.config["FORCED_FORMAT_ORDER_OFFSET"] + + self.FORCED_FORMAT_AUTOSCHEDULE_AMOUNT = bot.config["FORCED_FORMAT_AUTOSCHEDULE_AMOUNT"] + + # If we're testing, set the time to the current time so we start a new + # event immediately + if not self.is_production: + self.FIRST_EVENT_TIME = datetime.now( + timezone.utc).replace(second=0, microsecond=0) + 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] = {} + + self.ratings = mmr.Ratings() + + self.load_staff_settings() + + def load_staff_settings(self, view_only=False): + if os.path.isfile(STAFF_SETTINGS_PKL): + try: + with open(STAFF_SETTINGS_PKL, 'rb') as f: + settings_dict = dill.load(f) + if view_only: + print(settings_dict) + return + if "ROOM_MMR_THRESHOLD" in settings_dict: + self.room_mmr_threshold = settings_dict["ROOM_MMR_THRESHOLD"] + if "QUEUE_OPEN_TIME" in settings_dict: + self.QUEUE_OPEN_TIME = settings_dict["QUEUE_OPEN_TIME"] + if "JOINING_TIME" in settings_dict: + self.JOINING_TIME = settings_dict["JOINING_TIME"] + if "MATCHMAKING_BOTTOM_MMR" in settings_dict: + common.CONFIG["MATCHMAKING_BOTTOM_MMR"] = settings_dict["MATCHMAKING_BOTTOM_MMR"] + if "MATCHMAKING_TOP_MMR" in settings_dict: + common.CONFIG["MATCHMAKING_TOP_MMR"] = settings_dict["MATCHMAKING_TOP_MMR"] + if "FIRST_EVENT_TIME" in settings_dict: + common.CONFIG["FIRST_EVENT_TIME"] = settings_dict["FIRST_EVENT_TIME"] + self.FIRST_EVENT_TIME = datetime.combine(datetime.utcnow().date(), datetime.min.time( + ), tzinfo=timezone.utc) + timedelta(minutes=settings_dict["FIRST_EVENT_TIME"]) + if "SUB_RANGE_MMR_ALLOWANCE" in settings_dict: + self.SUB_RANGE_MMR_ALLOWANCE = settings_dict["SUB_RANGE_MMR_ALLOWANCE"] + if "PLACEMENT_PLAYER_MMR" in settings_dict: + self.PLACEMENT_PLAYER_MMR = settings_dict["PLACEMENT_PLAYER_MMR"] + common.CONFIG["PLACEMENT_PLAYER_MMR"] = settings_dict["PLACEMENT_PLAYER_MMR"] + if "PLAYERS_PER_ROOM" in settings_dict: + self.PLAYERS_PER_ROOM = settings_dict["PLAYERS_PER_ROOM"] + common.CONFIG["PLAYERS_PER_ROOM"] = settings_dict["PLAYERS_PER_ROOM"] + if "ALLOWED_VOTE_BUTTONS" in settings_dict and len(settings_dict["ALLOWED_VOTE_BUTTONS"]) > 0: + self.ALLOWED_VOTE_BUTTONS = settings_dict["ALLOWED_VOTE_BUTTONS"] + except BaseException: + print(traceback.format_exc()) + + def dump_staff_settings(self): + settings_dict = { + "ROOM_MMR_THRESHOLD": self.room_mmr_threshold, + "QUEUE_OPEN_TIME": self.QUEUE_OPEN_TIME, + "JOINING_TIME": self.JOINING_TIME, + "SUB_RANGE_MMR_ALLOWANCE": self.SUB_RANGE_MMR_ALLOWANCE, + "MATCHMAKING_BOTTOM_MMR": common.CONFIG["MATCHMAKING_BOTTOM_MMR"], + "MATCHMAKING_TOP_MMR": common.CONFIG["MATCHMAKING_TOP_MMR"], + "FIRST_EVENT_TIME": common.CONFIG["FIRST_EVENT_TIME"], + "PLACEMENT_PLAYER_MMR": self.PLACEMENT_PLAYER_MMR, + "PLAYERS_PER_ROOM": self.PLAYERS_PER_ROOM, + "ALLOWED_VOTE_BUTTONS": self.ALLOWED_VOTE_BUTTONS + } + try: + with open(STAFF_SETTINGS_PKL, 'wb') as f: + dill.dump(settings_dict, f) + except BaseException: + print(traceback.format_exc()) + @commands.Cog.listener() async def on_ready(self): self.GUILD = self.bot.get_guild(self.bot.config["guild_id"]) @@ -72,22 +346,76 @@ 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"]) + schedule_channel_id = self.bot.config["queue_schedule_channel"] + if schedule_channel_id: + self.SCHEDULE_CHANNEL = self.bot.get_channel( + schedule_channel_id) + + if not self.ALLOWED_VOTE_BUTTONS: + self.ALLOWED_VOTE_BUTTONS = SquadQueue.generate_vote_buttons( + self.PLAYERS_PER_ROOM) + + def is_queuebot(m: discord.Message): + return m.author.id == self.bot.user.id + purge_after_amt = 365 if self.is_production else 1 + purge_after = datetime.now(timezone.utc) - \ + timedelta(days=purge_after_amt) + try: + await self.LIST_CHANNEL.purge(check=is_queuebot, after=purge_after) + except BaseException: + print("Purging List channel failed", flush=True) + print(traceback.format_exc()) + try: + await self.SUB_CHANNEL.purge(check=is_queuebot, after=purge_after) + except BaseException: + print("Purging Sub channel failed", flush=True) + print(traceback.format_exc()) + try: + if schedule_channel_id: + await self.SCHEDULE_CHANNEL.purge(check=is_queuebot, after=purge_after) + # if len(self.forced_format_order) > 0 and self.queues_between_forced_format_queue: + # if self.queues_between_forced_format_queue: + # await self.autoschedule_forced_format_times() + except BaseException: + print("Purging Schedule channel failed", flush=True) + print(traceback.format_exc()) + + settings_group = SettingsGroup( + self, + name="settings", + description="Change bot configuration. Staff use only.") + self.bot.tree.add_command(settings_group) 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(f"Schedule Channel - {self.SCHEDULE_CHANNEL}", flush=True) print("Ready!", flush=True) + self.refresh_ratings.start() + self.refresh_helper_roles.start() + self.check_room_threads_task.start() + if not common.CONFIG["USE_THREADS"]: + self.maintain_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) @@ -110,6 +438,18 @@ async def queue_or_send(self, ctx, msg, delay=0): if delay > 0: await sendmsg.delete(delay=delay) + # 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. + def allowed_players_check(self, players: List[Player]): + if common.SERVER is common.Server.MKW: + return mkw_players_allowed(players, self.room_mmr_threshold) + elif common.SERVER is common.Server.MK8DX: + return mk8dx_players_allowed(players, self.room_mmr_threshold) + elif common.SERVER is common.Server.MKWorld: + return mkworld_players_allowed(players, self.room_mmr_threshold) + # goes thru the msg queue for each channel and combines them # into as few messsages as possible, then sends them @tasks.loop(seconds=2) @@ -119,228 +459,331 @@ async def send_queued_messages(self): channel_queue = self.msg_queue[channel] sentmsgs = [] msg = "" - for i in range(len(channel_queue)-1, -1, -1): + for i in range(len(channel_queue) - 1, -1, -1): msg = channel_queue.pop(i) + "\n" + msg if len(msg) > 1500: sentmsgs.append(msg) msg = "" if len(msg) > 0: sentmsgs.append(msg) - for i in range(len(sentmsgs)-1, -1, -1): + 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: 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 - def get_mogi(self, ctx): - if ctx.channel in self.ongoing_events.keys(): - return self.ongoing_events[ctx.channel] - return None + 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): + 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="c") + @app_commands.command(name="extend") @app_commands.guild_only() - async def can(self, interaction: discord.Interaction): - """Join a mogi""" - await interaction.response.defer() - async with self.LOCK: - member = interaction.user - 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 + async def extend(self, interaction: discord.Interaction, minutes: int): + """Extend the queue - player_team = mogi.check_player(member) + Parameters + ----------- + minutes: int + The number of minutes to add to the extension time. Can be negative. - if player_team is not None: - await interaction.followup.send(f"{interaction.user.mention} is already signed up.") - return + Staff use only. + """ + mogi = self.get_mogi(interaction) + if mogi is None or not mogi.started or not mogi.gathering: + await interaction.response.send_message("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).") - players = await mk8dx_150cc_mmr(self.URL, [member]) + @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) - 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. " - msg += "Please try again. If the problem continues then contact a staff member for help." - await interaction.followup.send(msg) - return + @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): + member = interaction.user + 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) + elif common.SERVER is common.Server.MKW: + # member = await self.bot.fetch_user(82862780591378432) + pass + if common.SERVER is common.Server.MKWorld: + # is actually a user and not a member + member = await self.bot.fetch_user(318637887597969419) + mogi = self.get_mogi(interaction) + if mogi is None or not mogi.started or not mogi.gathering: + await interaction.response.send_message("Queue has not started yet.") + 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 - squad = Team(players) - mogi.teams.append(squad) + player_team = mogi.check_player(member) + player = None if player_team is None else player_team.get_player( + member) + + if player is not None: + original_host_status = player.host + player.host = host + # 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 original_host_status and host: + await interaction.response.send_message(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 original_host_status and not host: + await interaction.response.send_message(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 original_host_status and host: + await interaction.response.send_message(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 original_host_status and not host: + await interaction.response.send_message(f"{interaction.user.mention} is already signed up.") + return - msg += f"{players[0].lounge_name} joined queue for mogi {discord.utils.format_dt(mogi.start_time, style='R')}, `[{mogi.count_registered()} players]`" + # FIRST look up the player - sometimes MK8DX bots add placement role to non placement players, + # so this will check the leaderboard first + players = self.ratings.get_rating([member]) + + msg = "" + # If the no rating was found... + if len(players) == 0 or players[0] is None: + # ... check for placement discord role ID: + placement_role_id = self.bot.config["placement_role_id"] + if 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 " \ + f"with a starting MMR of {starting_player_mmr}. If you believe this is a mistake, " \ + f"please contact a staff member for help.\n" + # ...if discord user doesn't have placement role ID, send error to Discord + else: + msg = f"{interaction.user.mention} fetch for MMR has failed and joining the queue was " \ + f"unsuccessful. Please try again. If the problem continues then contact a staff member " \ + f"for help." + await interaction.response.send_message(msg) + return - await interaction.followup.send(msg) - await self.check_room_channels(mogi) - await self.check_num_teams(mogi) + players[0].confirmed = True + players[0].host = host + squad = Team(players) + mogi.teams.append(squad) + host_str = " as a host " if host else " " + format_str = f"__**{mogi.format}**__ " if mogi.format else "" + msg += f"{players[0].lounge_name} joined the {self.ongoing_event.players_per_room} player {format_str}queue{host_str}closing at {discord.utils.format_dt(mogi.start_time)}, `[{mogi.count_registered()} players]`" + if common.SERVER is common.Server.MKW: + if players[0].is_matchmaking_mmr_adjusted: + msg += f"\nPlayer considered {players[0].adjusted_mmr} MMR for matchmaking purposes." + + event_status_launched = await self.check_close_event_change() + try: + await interaction.response.send_message(msg) + finally: + if event_status_launched: + await self.launch_mogi() @app_commands.command(name="d") @app_commands.guild_only() async def drop(self, interaction: discord.Interaction): """Remove user from mogi""" - await interaction.response.defer() - async with self.LOCK: - 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 = self.get_mogi(interaction) + if mogi is None or not mogi.started or not mogi.gathering: + await interaction.response.send_message("Queue has not started yet.") + return - 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") - return - mogi.teams.remove(squad) - msg = "Removed " - msg += ", ".join([p.lounge_name for p in squad.players]) - msg += f" from the mogi {discord.utils.format_dt(mogi.start_time, style='R')}" - msg += f", `[{mogi.count_registered()} players]`" + member = interaction.user + squad = mogi.check_player(member) + if squad is None: + await interaction.response.send_message(f"{member.display_name} is not currently in this event; type `/c` or `/ch` to join") + return + mogi.teams.remove(squad) + msg = "Removed " + msg += ", ".join([p.lounge_name for p in squad.players]) + msg += f" from the mogi {discord.utils.format_dt(mogi.start_time, style='R')}" + msg += f", `[{mogi.count_registered()} players]`" - await interaction.followup.send(msg) + event_status_launched = await self.check_close_event_change() + try: + await interaction.response.send_message(msg) + finally: + if event_status_launched: + await self.launch_mogi() + + @app_commands.command(name="host") + @app_commands.guild_only() + async def host(self, interaction: discord.Interaction): + """Display who is the host""" + room, _ = self.find_room_by_interaction(interaction) + if room is None: + await interaction.response.send_message( + f"More than {self.MOGI_LIFETIME} minutes have passed since mogi start, so this mogi has ended.", + ephemeral=True) + return + host_str = room.get_host_str() + if host_str == "": + host_str = "No one in this room queued as host! Ask someone who the host is." + await interaction.response.send_message(host_str) + + def find_room_by_interaction( + self, interaction: discord.Interaction) -> Tuple[Room | None, int]: + """Given an interaction, returns the Room and room number associated with the interaction. + Searches current and old, undeleted events. If the interaction is not associated with a Room, + (None, 1) is returned.""" + all_mogis = list(self.old_events) + if self.ongoing_event is not None: + all_mogis.insert(0, self.ongoing_event) + for mogi in all_mogis: + if mogi.channel_id_in_rooms(interaction.channel.id): + room = mogi.get_room_from_channel_id(interaction.channel.id) + bottom_room_num = len(mogi.rooms) + return room, bottom_room_num + return None, 1 @app_commands.command(name="sub") @app_commands.guild_only() - async def sub(self, interaction: discord.Interaction): + # @commands.cooldown(rate=1, per=120, type=commands.BucketType.user) + async def sub(self, interaction: discord.Interaction, races_left: app_commands.Range[int, 1, 12]): """Sends out a request for a sub in the sub channel. Only works in thread channels for SQ rooms.""" - is_room_thread = False - room = None - for mogi in self.ongoing_events.values(): - if mogi.is_room_thread(interaction.channel_id): - room = mogi.get_room_from_thread(interaction.channel_id) - 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) - 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) - 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" + 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 is 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 + + room, bottom_room_num = self.find_room_by_interaction(interaction) + if room is None: + 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 + 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 for {races_left} more races with any mmr\n" + elif room.room_num == 1: + msg += f"Room 1 is looking for a sub for {races_left} more races 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 for {races_left} more races 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 for {races_left} more races 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, + self.ratings.get_rating_from_discord_id, + self.SUB_RANGE_MMR_ALLOWANCE, + bottom_room_num, + is_restricted) 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: + """Continually display the list of confirmed players for a mogi in the list channel""" + 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" + format_str = f"{mogi.format} " if mogi.format else "" + msg += f"**Current {mogi.players_per_room} Player {format_str}Mogi List:**\n" + if common.SERVER is common.Server.MKW: + for i, player in enumerate(on_time_players, 1): + adjusted_mmr_text = f"MMR -> {player.adjusted_mmr} " if player.is_matchmaking_mmr_adjusted else "" + msg += f"`{i}.` {discord.utils.escape_markdown(player.lounge_name)} ({player.mmr} {adjusted_mmr_text}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): + adjusted_mmr_text = f"MMR -> {player.adjusted_mmr} " if player.is_matchmaking_mmr_adjusted else "" + msg += f"`{i}.` {player.lounge_name} ({player.mmr} {adjusted_mmr_text}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 = " `*`" if player in late_players else "" + msg += f"`{i}.` {discord.utils.escape_markdown(player.lounge_name)} ({player.mmr} MMR){late_str}\n" + if i % mogi.players_per_room == 0: + msg += "ㅤ\n" + elif common.SERVER is common.Server.MKWorld: + all_confirmed_players.sort(reverse=True) + for i, player in enumerate(all_confirmed_players, 1): + late_str = " `*`" if player in late_players else "" + msg += f"`{i}.` {discord.utils.escape_markdown(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 BaseException: + 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,63 +792,154 @@ 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()) + + async def update_schedule_channel(self): + """Update the Schedule Channel""" + + if self.SCHEDULE_CHANNEL: + msg = "" + + # maybe for mkworld if not 1 hour blocks for mogis + if common.SERVER is common.Server.MKW or common.SERVER is common.Server.MKWorld: + msg += "**Daily Queue Times:**\n\n" + + curr_time = self.compute_next_event_open_time() + self.JOINING_TIME + \ + self.DISPLAY_OFFSET_MINUTES + cutoff_time = curr_time + timedelta(hours=24) + + while curr_time < cutoff_time: + msg += f"{discord.utils.format_dt(curr_time, style='t')}\n" + curr_time += self.QUEUE_OPEN_TIME + + msg += "\n" + + if len(self.specific_player_amount_queue_times) > 0: + msg += "**List of Mogis with Set Amount of Players:**\n\n" + + for index, event in enumerate(self.specific_player_amount_queue_times): + msg += f"`#{index + 1}` **{event[1]} Players:** {discord.utils.format_dt(event[0])} - {discord.utils.format_dt(event[0], style='R')}\n" + + msg += "\n" + + current_rotation = ', '.join(self.forced_format_order) + + msg += f"**Current Rotation** - {current_rotation or 'N/A'}\n" + + time_between_ff_queues = self.queues_between_forced_format_queue * self.QUEUE_OPEN_TIME + time_between_ff_queues_hours = time_between_ff_queues.total_seconds() / 3600 + + if not current_rotation: + msg += f"**Current Interval** - N/A\n\n" + elif time_between_ff_queues_hours.is_integer(): + msg += f"**Current Interval** - {int(time_between_ff_queues_hours)} hours\n\n" + else: + time_between_ff_queues_minutes = time_between_ff_queues.total_seconds() / 60 + msg += f"**Current Interval** - {int(time_between_ff_queues_minutes)} minutes\n\n" + + msg += "**List of Mogis with Scheduled Formats:**\n\n" + for index, event in enumerate(self.forced_format_times): + msg += f"`#{index + 1}` **{event[1]}:** {discord.utils.format_dt(event[0])} - {discord.utils.format_dt(event[0], style='R')}\n" + 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_format_messages(len(new_messages)) + + try: + for i, message in enumerate(new_messages): + if i < len(self.format_messages): + old_message = self.format_messages[i] + await old_message.edit(content=message) + else: + new_message = await self.SCHEDULE_CHANNEL.send(message) + self.format_messages.append(new_message) + except BaseException: + await self.delete_format_messages(0) + for i, message in enumerate(new_messages): + new_message = await self.SCHEDULE_CHANNEL.send(message) + self.format_messages.append(new_message) + + async def delete_format_messages(self, new_list_size: int): + try: + messages_to_delete = [] + while len(self.format_messages) > new_list_size: + messages_to_delete.append(self.format_messages.pop()) + if self.SCHEDULE_CHANNEL and len(messages_to_delete) > 0: + await self.SCHEDULE_CHANNEL.delete_messages(messages_to_delete) + except Exception as e: + 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): + if message.author.bot or not ( + message.content.isdecimal() and 12 <= int( + message.content) <= 180): + return + mogi = None + if self.ongoing_event is not None: + mogi = self.ongoing_event if self.ongoing_event.channel_id_in_rooms( + message.channel.id) else None + if mogi is None: + mogi = discord.utils.find(lambda m: m.channel_id_in_rooms( + message.channel.id), self.old_events) + if mogi is None: 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 room = discord.utils.find( - lambda room: room.thread.id == message.channel.id, mogi.rooms) - if not room or not room.teams: + lambda r: r.channel.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.""" + """Displays the scoreboard of the room. Only works in thread channels for queue rooms.""" + if common.SERVER is common.Server.MKW: + await interaction.response.send_message(f"Command is only usable for MK8DX and MKWorld.", ephemeral=True) + return - mogi = discord.utils.find(lambda mogi: mogi.is_room_thread( - interaction.channel_id), self.ongoing_events.values()) + 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.channel_id_in_rooms( + 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) - + lambda room: room.channel.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)) + format_ = round(mogi.players_per_room / len(room.teams)) - msg = f"!submit {format} {get_tier(room.mmr_average - 500)}\n" + msg = f"!submit {format_} {room.tier}\n" for team in room.teams: for player in team.players: msg += f"{player.lounge_name} {player.score}\n" - if format != 1: + if format_ != 1: msg += "\n" await interaction.response.send_message(msg) @@ -413,97 +947,548 @@ async def scoreboard(self, interaction: discord.Interaction): @app_commands.guild_only() async def remove_player(self, interaction: discord.Interaction, member: discord.Member): """Removes a specific player from the current queue. Staff use only.""" - await interaction.response.defer() - async with self.LOCK: - 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 = self.get_mogi(interaction) + if mogi is None or not mogi.started or not mogi.gathering: + await interaction.response.send_message("Queue has not started yet.") + return - 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") - return - mogi.teams.remove(squad) - msg = "Staff has removed " - msg += ", ".join([p.lounge_name for p in squad.players]) - msg += f" from the mogi {discord.utils.format_dt(mogi.start_time, style='R')}" - msg += f", `[{mogi.count_registered()} players]`" + squad = mogi.check_player(member) + if squad is None: + await interaction.response.send_message(f"{member.display_name} is not currently in this event") + return + mogi.teams.remove(squad) + msg = "Staff has removed " + msg += ", ".join([p.lounge_name for p in squad.players]) + msg += f" from the mogi {discord.utils.format_dt(mogi.start_time, style='R')}" + msg += f", `[{mogi.count_registered()} players]`" - await interaction.followup.send(msg) + event_status_launched = await self.check_close_event_change() + try: + await interaction.response.send_message(msg) + finally: + if event_status_launched: + await self.launch_mogi() + + @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): + 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="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}\n" + msg += f"MATCHMAKING BOTTOM MMR: {common.CONFIG['MATCHMAKING_BOTTOM_MMR']}\n" + msg += f"MATCHMAKING TOP MMR: {common.CONFIG['MATCHMAKING_TOP_MMR']}" + 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): + async def update_current_event_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> List[app_commands.Choice[str]]: + total = self.PLAYERS_PER_ROOM + choices = [] + + for i in range(1, total): + if total % i != 0: + continue + fmt = "FFA" if i == 1 else f"{i}v{i}" + if current.lower() in fmt.lower(): + choices.append(app_commands.Choice(name=fmt, value=fmt)) + + if "voting".startswith(current.lower()): + choices.append(app_commands.Choice(name="Voting", value="Voting")) + + return choices[:25] + + @app_commands.command(name="update_current_event_format") + @app_commands.autocomplete(format_string=update_current_event_autocomplete) + @app_commands.guild_only() + async def update_current_event_format(self, interaction: discord.Interaction, format_string: str): + """Update the currently gathering mogi's format. Staff use only.""" + + if format_string == "Voting": + self.ongoing_event.format = None + elif format_string == "FFA": + self.ongoing_event.format = "FFA" + else: + try: + num_players_per_team = int(format_string.split("v")[0]) + except (ValueError, IndexError): + await interaction.response.send_message( + f"Invalid format string: `{format_string}`", ephemeral=True + ) + return + + if self.PLAYERS_PER_ROOM % num_players_per_team != 0: + await interaction.response.send_message( + f"`{format_string}` is not valid — {num_players_per_team} does not divide evenly into {self.PLAYERS_PER_ROOM}.", + ephemeral=True + ) + return + + self.ongoing_event.format = format_string + + await interaction.response.send_message(f"The currently gathering event's format is now {format_string}") + + async def timezone_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + current_upper = current.upper() + now = datetime.now(pytz.utc) + active_timezones = [] + + for tz, info in self.timezones.items(): + full_timezone_name = info['zone_name'] + tz_info = pytz.timezone(full_timezone_name) + local_now = now.astimezone(tz_info) + + if local_now.utcoffset().total_seconds() / 3600 == info['offset']: + if current_upper in tz.upper(): + active_timezones.append((tz, info['offset'])) + + active_timezones.sort(key=lambda x: (x[1], x[0])) + + choices = [ + app_commands.Choice(name=f"{tz} (UTC{offset:+})", value=tz) + for tz, offset in active_timezones + ] + + return choices[:25] + + async def date_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + timezone_choice = pytz.timezone("Etc/UTC") + + if 'options' in interaction.data: + for option in interaction.data['options']: + if option['name'] == 'tzone': + abbreviation = option['value'] + timezone_info = self.timezones.get(abbreviation, {}) + full_timezone_name = timezone_info.get( + "zone_name", "Etc/UTC") + timezone_choice = pytz.timezone(full_timezone_name) + break + + today = datetime.now(timezone_choice) + self.QUEUE_OPEN_TIME + today_date = today.date() + options = [] + for i in range(25): + date = today_date + timedelta(days=i) + name = date.strftime("%m/%d") + value = date.isoformat() + options.append(app_commands.Choice(name=name, value=value)) + + return options + + def compute_first_event_of_date_open_time(self, date): + time_elapsed = date - 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 time_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + date = None + timezone_choice = pytz.timezone("Etc/UTC") + timezone_offset = 0 + + if 'options' in interaction.data: + for option in interaction.data['options']: + if option['name'] == 'date': + date = option['value'] + if option['name'] == 'tzone': + abbreviation = option['value'] + timezone_info = self.timezones.get(abbreviation, {}) + full_timezone_name = timezone_info.get( + "zone_name", "Etc/UTC") + timezone_offset = timezone_info.get( + "offset", 0) + timezone_choice = pytz.timezone(full_timezone_name) + + time_options = [] + if date and timezone_choice: + try: + selected_date = datetime.fromisoformat(date) + selected_date = selected_date.astimezone( + timezone.utc) - timedelta(hours=timezone_offset) + + curr_time = datetime.now( + timezone.utc) + curr_date = self.compute_first_event_of_date_open_time( + selected_date) + cutoff_date = selected_date + timedelta(hours=24) + + while curr_date < cutoff_date and len(time_options) < 25: + if curr_date > curr_time: + adjusted_date = curr_date + self.JOINING_TIME + self.DISPLAY_OFFSET_MINUTES + tz_adjusted_date = adjusted_date + \ + timedelta(hours=timezone_offset) + dt_str = adjusted_date.isoformat() + name = tz_adjusted_date.strftime( + f"%m/%d - %H:%M:%S") + time_options.append( + app_commands.Choice(name=name, value=dt_str)) + curr_date += self.QUEUE_OPEN_TIME + + except ValueError as e: + print(f"Error parsing date: {e}", flush=True) + + else: + time_options = [f"{hour:02d}:00" for hour in range(24)] + return [app_commands.Choice(name=time, value=time) for time in time_options] + + return time_options + + async def format_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + options = ["FFA", "2v2", "3v3", "4v4", "6v6"] + return [app_commands.Choice(name=option, value=option) for option in options] + + @app_commands.command(name="schedule_forced_format_times") + @app_commands.autocomplete(tzone=timezone_autocomplete) + @app_commands.autocomplete(date=date_autocomplete) + @app_commands.autocomplete(time=time_autocomplete) + @app_commands.autocomplete(format=format_autocomplete) + @app_commands.guild_only() + async def schedule_forced_format_times(self, interaction: discord.Interaction, tzone: str, date: str, time: str, format: str): + """Schedule mogis to have all rooms be specific format. Staff use only.""" + curr_time = datetime.now(timezone.utc) + dt = datetime.fromisoformat(time).replace(tzinfo=timezone.utc) + timestamp = dt.timestamp() + + if curr_time > dt: + msg = "" + msg += f"Timestamp {timestamp} represents {time} and is in the past, submit a future date.\n" + msg += "This timestamp has not been added." + await interaction.response.send_message(msg) return - msg = "List of new Dates:\n" + new_event = (dt, format) + event_dict = {event[0]: event for event in self.forced_format_times} + event_dict[new_event[0]] = new_event + updated_list = list(event_dict.values()) + self.forced_format_times = sorted(updated_list, key=lambda x: x[0]) + + if self.SCHEDULE_CHANNEL: + await self.update_schedule_channel() + + msg = f"Added new {format} mogi at {discord.utils.format_dt(dt)}" + + await interaction.response.send_message(msg) + + async def autoschedule_forced_format_times(self): + """Schedule the next batch of forced format mogis automatically.""" + event_dict = {event[0]: event for event in self.forced_format_times} + time_between_ff_queues = self.queues_between_forced_format_queue * self.QUEUE_OPEN_TIME + + last_event = self.FORCED_FORMAT_FIRST_EVENT + next_event_open_time = self.compute_next_event_open_time() + start_index = 0 + while last_event < datetime.now(timezone.utc) - time_between_ff_queues: + last_event += time_between_ff_queues + start_index += 1 + + ongoing_format = None + if last_event == next_event_open_time: + current_index = ( + start_index + self.FORCED_FORMAT_ORDER_OFFSET) % len(self.forced_format_order) + ongoing_format = self.forced_format_order[current_index] + + if ongoing_format != "FFA": + try: + num_players_per_team = int(ongoing_format.split("v")[0]) + except (ValueError, IndexError): + print( + f"Invalid format string encountered: {ongoing_format}", flush=True) + ongoing_format = None + + if self.PLAYERS_PER_ROOM % num_players_per_team != 0: + print( + f"Format {ongoing_format} is invalid: {num_players_per_team} does not divide evenly into {self.PLAYERS_PER_ROOM}") + ongoing_format = None + + last_event += time_between_ff_queues + start_index += 1 + + total_iterations = self.FORCED_FORMAT_AUTOSCHEDULE_AMOUNT * \ + len(self.forced_format_order) + + for i in range(total_iterations): + current_index = ( + start_index + i + self.FORCED_FORMAT_ORDER_OFFSET) % len(self.forced_format_order) + format = self.forced_format_order[current_index] + + new_event_time = last_event + i * time_between_ff_queues + \ + self.JOINING_TIME + self.DISPLAY_OFFSET_MINUTES + new_event = (new_event_time, format) + event_dict[new_event[0]] = new_event + + updated_list = list(event_dict.values()) + self.forced_format_times = sorted(updated_list, key=lambda x: x[0]) + + if self.SCHEDULE_CHANNEL: + await self.update_schedule_channel() + + return ongoing_format + + async def remove_time_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + choices = [ + app_commands.Choice( + name=f"{dt.strftime('%Y-%m-%d %H:%M:%S')} - {fmt}", value=dt.isoformat() + ) + for dt, fmt in self.forced_format_times[:25] + ] + return choices + + @app_commands.command(name="remove_forced_format_time") + @app_commands.autocomplete(time=remove_time_autocomplete) + @app_commands.guild_only() + async def remove_forced_format_time(self, interaction: discord.Interaction, time: str): + """Remove a specific forced format time by datetime. Staff use only.""" + try: + dt_to_remove = datetime.fromisoformat(time) + + original_count = len(self.forced_format_times) + self.forced_format_times = [ + entry for entry in self.forced_format_times if entry[0] != dt_to_remove + ] + + if len(self.forced_format_times) < original_count: + await interaction.response.send_message(f"Removed {discord.utils.format_dt(dt_to_remove, 'f')} from the Forced Format times.") + else: + await interaction.response.send_message(f"No entry found for {time}.") + + if self.SCHEDULE_CHANNEL: + await self.update_schedule_channel() + except ValueError: + await interaction.response.send_message("Invalid datetime format provided.") + + @app_commands.command(name="clear_forced_format_times") + @app_commands.guild_only() + async def clear_forced_format_times(self, interaction: discord.Interaction): + """Clears current list of forced format times. Staff use only.""" + self.forced_format_times = [] + if self.SCHEDULE_CHANNEL: + await self.update_schedule_channel() + + await interaction.response.send_message("Cleared list of Forced Format Times.") + + @app_commands.command(name="schedule_player_amount_times") + @app_commands.autocomplete(tzone=timezone_autocomplete) + @app_commands.autocomplete(date=date_autocomplete) + @app_commands.autocomplete(time=time_autocomplete) + @app_commands.guild_only() + async def schedule_player_amount_times(self, interaction: discord.Interaction, tzone: str, date: str, time: str, player_amount: Range[int, 2]): + """Schedule mogis to be rooms of certain sizes. Staff use only.""" curr_time = datetime.now(timezone.utc) - new_sq_dates = [] + dt = datetime.fromisoformat(time).replace(tzinfo=timezone.utc) + timestamp = dt.timestamp() - for timestamp in timestamps: - date = datetime.fromtimestamp(timestamp, timezone.utc) - truncated_date = date.replace( - minute=0, second=0, microsecond=0) + if curr_time > dt: + msg = "" + msg += f"Timestamp {timestamp} represents {time} and is in the past, submit a future date.\n" + msg += "This timestamp has not been added." + await interaction.response.send_message(msg) + return - if curr_time > truncated_date: - msg = "" - msg += f"Timestamp {timestamp} represents {truncated_date} and is in the past, submit a future date.\n" - msg += "No timestamps from this usage of the command have been added." - await self.queue_or_send(ctx, msg) - return + new_event = (dt, player_amount) + event_dict = { + event[0]: event for event in self.specific_player_amount_queue_times} + event_dict[new_event[0]] = new_event + updated_list = list(event_dict.values()) + self.specific_player_amount_queue_times = sorted( + updated_list, key=lambda x: x[0]) - new_sq_dates.append(truncated_date) - msg += f"{truncated_date}\n" + if self.SCHEDULE_CHANNEL: + await self.update_schedule_channel() - self.sq_times += new_sq_dates - self.sq_times = list(set(self.sq_times)) + msg = f"Added new {player_amount} player mogi at {discord.utils.format_dt(dt)}" + + await interaction.response.send_message(msg) + + async def remove_player_amount_time_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + choices = [ + app_commands.Choice( + name=f"{dt.strftime('%Y-%m-%d %H:%M:%S')} - {fmt}", value=dt.isoformat() + ) + for dt, fmt in self.specific_player_amount_queue_times[:25] + ] + return choices + + @app_commands.command(name="remove_player_amount_time") + @app_commands.autocomplete(time=remove_player_amount_time_autocomplete) + @app_commands.guild_only() + async def remove_player_amount_time(self, interaction: discord.Interaction, time: str): + """Remove a specific set player amount queue time by datetime. Staff use only.""" + try: + dt_to_remove = datetime.fromisoformat(time) + + original_count = len(self.specific_player_amount_queue_times) + self.specific_player_amount_queue_times = [ + entry for entry in self.specific_player_amount_queue_times if entry[0] != dt_to_remove + ] + + if len(self.specific_player_amount_queue_times) < original_count: + await interaction.response.send_message(f"Removed {discord.utils.format_dt(dt_to_remove, 'f')} from the Set Player Amount times.") + else: + await interaction.response.send_message(f"No entry found for {time}.") + + if self.SCHEDULE_CHANNEL: + await self.update_schedule_channel() + except ValueError: + await interaction.response.send_message("Invalid datetime format provided.") + + @app_commands.command(name="clear_player_amount_times") + @app_commands.guild_only() + async def clear_player_amount_times(self, interaction: discord.Interaction): + """Clears current list of Set Player Amount times. Staff use only.""" + self.specific_player_amount_queue_times = [] + if self.SCHEDULE_CHANNEL: + await self.update_schedule_channel() + + await interaction.response.send_message("Cleared list of Set Player Amount Times.") + + @app_commands.command(name="schedule_sq_times") + @app_commands.autocomplete(tzone=timezone_autocomplete) + @app_commands.autocomplete(date=date_autocomplete) + @app_commands.autocomplete(time=time_autocomplete) + @app_commands.guild_only() + async def schedule_sq_times(self, interaction: discord.Interaction, tzone: str, date: str, time: str): + """Schedule Squad Queue times. Staff use only.""" + curr_time = datetime.now(timezone.utc) + dt = datetime.fromisoformat(time).replace(tzinfo=timezone.utc) + timestamp = dt.timestamp() + + if curr_time > dt: + msg = "" + msg += f"Timestamp {timestamp} represents {time} and is in the past, submit a future date.\n" + msg += "This timestamp has not been added." + await interaction.response.send_message(msg) + return + self.sq_times.append(dt) + self.sq_times = list(set(self.sq_times)) list.sort(self.sq_times) - await self.queue_or_send(ctx, msg) + msg = "List of Squad Queue Times:\n" + + for index, date in enumerate(self.sq_times): + msg += f"{index + 1}) {discord.utils.format_dt(date)}\n" + + await interaction.response.send_message(msg) @app_commands.command(name="peek_sq_times") @app_commands.guild_only() @@ -516,6 +1501,27 @@ async def peek_sq_times(self, interaction: discord.Interaction): await interaction.response.send_message(msg) + async def sq_time_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + choices = [ + app_commands.Choice(name=dt.strftime( + "%Y-%m-%d %H:%M:%S"), value=dt.isoformat()) + for dt in self.sq_times[:25] + ] + return choices + + @app_commands.command(name="remove_sq_time") + @app_commands.autocomplete(time=sq_time_autocomplete) + @app_commands.guild_only() + async def remove_sq_time(self, interaction: discord.Interaction, time: str): + """Remove a specific Squad Queue time. Staff use only.""" + dt_to_remove = datetime.fromisoformat(time) + + if dt_to_remove in self.sq_times: + self.sq_times.remove(dt_to_remove) + await interaction.response.send_message(f"Removed {discord.utils.format_dt(dt_to_remove, 'f')} from the Squad Queue times.") + else: + await interaction.response.send_message("Specified time not found in the Squad Queue times.", ephemeral=True) + @app_commands.command(name="clear_sq_times") @app_commands.guild_only() async def clear_sq_times(self, interaction: discord.Interaction): @@ -524,310 +1530,468 @@ 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) - - async def end_voting(self): + @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 and not mogi.format: + 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) + 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: + format_str = f"{mogi.format} " if mogi.format else "" + rooms_str = "Room" if len(mogi.rooms) == 1 else "Rooms" + await history_channel.send(f"{discord.utils.format_dt(mogi.display_time)} - {len(mogi.rooms)} {format_str}{rooms_str}") + 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) + f"Skipping room {index} in function write_history.", + flush=True) continue msg = room.view.header_text - msg += f"{room.thread.jump_url}\n" + if common.SERVER is common.Server.MKW: + msg += f"{room.view.room_start_msg_link}\n" + elif common.SERVER is common.Server.MK8DX: + msg += f"{room.channel.jump_url}\n" + elif common.SERVER is common.Server.MKWorld: + msg += f"{room.channel.jump_url}\n" msg += room.view.teams_text msg += "ㅤ" - await self.HISTORY_CHANNEL.send(msg) + try: + await history_channel.send(msg) + except discord.HTTPException as e: + print( + f"HTTP error while writing room {index} in function write_history, skipping to next room.", + flush=True) 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)) + async def check_room_threads(self, mogi: Mogi): num_created_rooms = len(mogi.rooms) - if num_created_rooms >= num_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): + if not common.CONFIG["USE_THREADS"]: + mogi.rooms.append(Room(None, i + 1, None, self.TIER_INFO)) + continue + 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) + # Address race condition - race condition would result in + # making too many rooms + if len(mogi.rooms) >= mogi.max_possible_rooms: + await room_channel.delete() + return + 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)) + mogi.rooms.append(Room(None, i + 1, room_channel, self.TIER_INFO)) + + @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 + if not mogi.format: + 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: + + await self.lockdown(mogi.mogi_channel) + 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.check_room_threads(mogi) + + 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)) + + 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 = mogi.generate_proposed_list(self.allowed_players_check) + await mogi.populate_host_fcs() + for room_number, room_players in enumerate( + divide_chunks(proposed_list, mogi.players_per_room), 1): + mogi_channel_msg = f"`Room {room_number} - Player List`\n" + room_msg = mogi_channel_msg + for player_num, player in enumerate(room_players, 1): + added_str = ": **Added from late players**" if player in late_player_list else "" + adjusted_mmr_text = f"MMR -> {player.adjusted_mmr} " if player.is_matchmaking_mmr_adjusted else "" + mogi_channel_msg += f"`{player_num}.` {discord.utils.escape_markdown(player.lounge_name)} ({player.mmr} {adjusted_mmr_text}MMR){added_str}\n" + room_msg += f"`{player_num}.` {discord.utils.escape_markdown(player.lounge_name)} ({player.mmr} MMR)\n" + if not self.allowed_players_check(room_players): + mogi_channel_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]) + + potential_host_str = curr_room.get_host_str() + if potential_host_str == "": + potential_host_str += f"No one in the room queued as host. Decide a host amongst yourselves." + room_msg += f"\n{potential_host_str}\n" + + if not mogi.format: + vote_formats = [b["format_name"] for b in mogi.buttons] + vote_formats_str = ", ".join(vote_formats) + room_msg += f"Vote for format {vote_formats_str}.\n" + + room_msg += f""" +{player_mentions} {extra_member_mentions} + +If you need staff's assistance, use the `/ping_staff` command in this channel.""" + + # The below try/except clause around Room.prepare_room_channel + # only runs if the config's USE_THREADS is set to false + try: + await curr_room.prepare_room_channel(self.GUILD, all_events=([self.ongoing_event] + self.old_events)) + # Non-fatal error, message already sent to room channel, + # continue with room creation + except mogi_objects.RoleAddFailure: + pass + # semi-critical failures, but the room can proceed + except mogi_objects.RoleNotFound as e: + await mogi.mogi_channel.send(f"**ERROR:** {e}") + # critical failure, but we can recover by creating a thread + except (mogi_objects.WrongChannelType, mogi_objects.NoFreeChannels) as e: + await mogi.mogi_channel.send(f"**ERROR:** {e}, attempting to create room thread instead...") + room_name = f"{mogi.display_time.month}/{mogi.display_time.day}, {mogi.display_time.hour}:{mogi.display_time.minute:02}:00 - Room {room_number}" + curr_room.channel = await self.GENERAL_CHANNEL.create_thread(name=room_name, auto_archive_duration=60, invitable=False) + # critical failure, the room cannot proceed + except mogi_objects.WrongChannelType as e: + await mogi.mogi_channel.send(f"**ERROR:** {e}") + continue + + try: + sent_msg = await curr_room.channel.send(room_msg) + view = VoteView( + room_players, + curr_room.channel, + mogi, + curr_room, + self.ROOM_JOIN_PENALTY_TIME, + sent_msg.jump_url) + curr_room.view = view + await view.create_message() + 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 + mogi_channel_msg += err_msg + print(traceback.format_exc()) + try: - await mogi.mogi_channel.send(msg) - except Exception as e: + await mogi.mogi_channel.send(mogi_channel_msg) + 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()] - 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" + 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: + mogi_channel_msg = "`Late players:`\n" + for i, player in enumerate(not_in_proposed_list, 1): + removed_str = ": **Removed from player list**" if player in regular_player_list else "" + adjusted_mmr_text = f"MMR -> {player.adjusted_mmr} " if player.is_matchmaking_mmr_adjusted else "" + mogi_channel_msg += f"`{i}.` {player.lounge_name} ({int(player.mmr)} {adjusted_mmr_text}MMR) {removed_str}\n" try: - await mogi.mogi_channel.send(msg) - except Exception as e: + await mogi.mogi_channel.send(mogi_channel_msg) + 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() - - async def check_num_teams(self, 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: + 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 + if not common.CONFIG["USE_THREADS"]: + asyncio.create_task( + self.ongoing_event.assign_roles(guild=self.GUILD)) + asyncio.create_task(SquadQueue.handle_voting_and_history( + self.ongoing_event, self.HISTORY_CHANNEL)) + if mogi.format or mogi.players_per_room != self.PLAYERS_PER_ROOM: + if self.SCHEDULE_CHANNEL: + await self.update_schedule_channel() + self.old_events.append(self.ongoing_event) + self.ongoing_event = None + + async def check_close_event_change(self) -> Tuple[bool, bool]: + """Returns a bool indicating if the event was gathering but this function then closed the mogi depending on + the time and number of players or other logic""" + mogi = self.ongoing_event + if mogi is not None: + # If it's not automated, don't run this + # If the mogi has not started, don't run this + # If the mogi is not gathering, don't run this + # If the mogi has already made the rooms, don't run this. + # This logic was taken from a much more complex bot. It could be greatly simplified since all events here + # are automated and follow a certain flow, but I am not going to + # change what isn't broken. + if not mogi.is_automated or not mogi.started or mogi.making_rooms_run or not mogi.gathering: + return False + cur_time = datetime.now(timezone.utc) + force_start_time = mogi.start_time + \ + self.EXTENSION_TIME + mogi.additional_extension + if force_start_time <= cur_time: 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.") - - async def ongoing_mogi_checks(self): - for mogi in self.ongoing_events.values(): - # 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: - mogi.gathering = False - if mogi.start_time - self.QUEUE_OPEN_TIME + self.JOINING_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: + self.cur_extension_message = None + return True + num_leftover_teams = mogi.count_registered() % ( + mogi.players_per_room // mogi.max_player_per_team) + num_needed_teams = ( + mogi.players_per_room // mogi.max_player_per_team) - num_leftover_teams + if common.SERVER is common.Server.MKW and ( + force_start_time - + timedelta( + minutes=1)) <= cur_time and num_leftover_teams != 0: + if not mogi.has_checked_auto_extend: + mogi.has_checked_auto_extend = True + if (mogi.max_possible_rooms == 0 and num_needed_teams <= 2) or ( + num_needed_teams - 1) <= mogi.max_possible_rooms: + self.cur_extension_message = f"The extension criteria has been met, so queueing has been extended for 2 more minutes." + mogi.additional_extension += timedelta(minutes=2) + return False + if mogi.start_time <= cur_time and mogi.gathering: + # check if there are an even amount of teams since we are past the queue time + # If have en even number of players... + if num_leftover_teams == 0: + # ...and none of the rooms will be cancelled, close the queue and begin + if not mogi.any_room_cancelled(self.allowed_players_check): mogi.gathering = False + self.cur_extension_message = None + return True + # ...any of the rooms will be cancelled, set the error message 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.") - 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") - await self.add_teams_to_rooms(mogi, (mogi.start_time.minute) % 60, True) + self.last_extension_message_timestamp = datetime.now( + timezone.utc).replace(second=0, microsecond=0) + minutes_left = (force_start_time - + cur_time).seconds // 60 + self.cur_extension_message = f"One or more of the rooms will be cancelled, so the extension " \ + f"will continue. Starting in {minutes_left + 1} minute(s) " \ + f"regardless." + + else: + if not mogi.any_room_cancelled(self.allowed_players_check) and common.SERVER is common.Server.MKW and num_needed_teams > 8: + mogi.gathering = False + self.cur_extension_message = None + await mogi.mogi_channel.send(f"New room requires too many people to start ({num_needed_teams}), starting rooms now.") + return True + cur_extension_timestamp = datetime.now( + timezone.utc).replace(second=0, microsecond=0) + # At this point, we're in the extension time. So if the extension timestamp is in a different + # minute than the last one, we set the new extension + # message to be sent. + if cur_extension_timestamp != self.last_extension_message_timestamp: + self.last_extension_message_timestamp = cur_extension_timestamp + minutes_left = (force_start_time - + cur_time).seconds // 60 + self.cur_extension_message = f"Need {num_needed_teams} more player(s) to start immediately. Starting in {minutes_left + 1} minute(s) regardless." + return False + + async def launch_mogi(self): + mogi = self.ongoing_event + if mogi is not None: + await mogi.mogi_channel.send("Mogi is now closed; players can no longer join or drop from the event") + await self.delete_list_messages(0) + await self.add_teams_to_rooms(mogi, mogi.start_time.minute % 60, True) + + async def check_send_extension_message(self): + if self.cur_extension_message is not None: + to_send = self.cur_extension_message + self.cur_extension_message = None + if self.ongoing_event is not None: + await self.ongoing_event.mogi_channel.send(to_send) 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) + format_str = f"**{self.ongoing_event.format}** " if self.ongoing_event.format else "" + await self.ongoing_event.mogi_channel.send( + f"A queue is gathering for the {self.ongoing_event.players_per_room} player {format_str}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): """Scheduler that checks if it should start mogis and close them""" # 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 + # This is unacceptable considering people are relying on these mogis to + # run, so we will not allow this routine to stop try: - await self.scheduler_mogi_start() + if self.ongoing_event is None: + await self.schedule_que_event() except Exception as e: - print(e) + print(traceback.format_exc()) try: - await self.ongoing_mogi_checks() + await self.scheduler_mogi_start() except Exception as e: - print(e) - - @tasks.loop(minutes=1) - async def que_scheduler(self): + print(traceback.format_exc()) try: - if not self.scheduled_events or len(self.scheduled_events[self.GUILD]) == 0: - await self.schedule_que_event() + if await self.check_close_event_change(): + await self.launch_mogi() + await self.check_send_extension_message() except Exception as e: - print(e) + print(traceback.format_exc()) - async def schedule_que_event(self): - """Schedules queue for the next hour in the given channel.""" + 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 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) + format = None + while len( + self.forced_format_times) > 0 and datetime.now(timezone.utc) > self.forced_format_times[0][0]: + self.forced_format_times.pop(0) + if len(self.forced_format_order) > 0 and self.queues_between_forced_format_queue and len(self.forced_format_times) == 0: + format = await self.autoschedule_forced_format_times() + if len( + self.forced_format_times) > 0 and next_event_open_time + self.JOINING_TIME + self.DISPLAY_OFFSET_MINUTES == self.forced_format_times[0][0]: + last_event = self.forced_format_times.pop(0) + format = last_event[1] + + if format != "FFA": + try: + num_players_per_team = int(format.split("v")[0]) + except (ValueError, IndexError): + print( + f"Invalid format string encountered: {format}", flush=True) + format = None - if self.GUILD not in self.scheduled_events.keys(): - self.scheduled_events[self.GUILD] = [] + if self.PLAYERS_PER_ROOM % num_players_per_team != 0: + print( + f"Format {format} is invalid: {num_players_per_team} does not divide evenly into {self.PLAYERS_PER_ROOM}") + format = None + + if len(self.forced_format_order) > 0 and self.queues_between_forced_format_queue and len(self.forced_format_times) == 0: + format = await self.autoschedule_forced_format_times() + + player_amount = self.PLAYERS_PER_ROOM + buttons = self.ALLOWED_VOTE_BUTTONS + while len( + self.specific_player_amount_queue_times) > 0 and datetime.now(timezone.utc) > self.specific_player_amount_queue_times[0][0]: + self.specific_player_amount_queue_times.pop(0) + if len( + self.specific_player_amount_queue_times) > 0 and next_event_open_time + self.JOINING_TIME + self.DISPLAY_OFFSET_MINUTES == self.specific_player_amount_queue_times[0][0]: + last_event = self.specific_player_amount_queue_times.pop(0) + player_amount = last_event[1] + buttons = SquadQueue.generate_vote_buttons(player_amount) + + while len( + self.sq_times) > 0 and datetime.now(timezone.utc) > self.sq_times[0]: + self.sq_times.pop(0) + if len( + self.sq_times) > 0 and next_event_open_time + self.JOINING_TIME + self.DISPLAY_OFFSET_MINUTES == 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.scheduled_events[self.GUILD].append(mogi) + self.next_event = Mogi(sq_id=1, + max_players_per_team=1, + players_per_room=player_amount, + buttons=buttons, + mogi_channel=self.MOGI_CHANNEL, + is_automated=True, + start_time=next_event_start_time, + display_time=next_event_display_time, + format=format) - 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 +1999,232 @@ 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] + f"Deleting {mogi.start_time} Mogi at {curr_time}", + flush=True) + self.old_events.remove(mogi) + except Exception as e: + print(traceback.format_exc()) + + @tasks.loop(minutes=10) + async def maintain_roles(self): + """Removes roles from people who are no longer supposed to see tier channels.""" + try: + should_start_role_removal = ( + self.bot_startup_time + + timedelta( + minutes=self.MOGI_LIFETIME)) <= datetime.now( + timezone.utc) + if should_start_role_removal: + # Fetch all members (API call) for server to ensure role information is up-to-date + # WARNING: if your server is large, you need a higher loop time + # on the task + members = [member async for member in self.GUILD.fetch_members(limit=None)] + + # Gather all discord.Role objects + tier_roles = [] + tier_role_data = common.CONFIG["TIER_CHANNELS"] + for tier, tier_data in tier_role_data.items(): + tier_roles.append(self.GUILD.get_role( + tier_data["tier_role_id"])) + + # Create a list of all current and existing old events + all_mogis = [ev for ev in self.old_events if ev is not None] + if self.ongoing_event is not None: + all_mogis.insert(0, self.ongoing_event) + + # Create a list of all Rooms who have a valid room role + all_rooms = [] + for mogi in all_mogis: + all_rooms.extend( + [r for r in mogi.rooms if r is not None and r.room_role is not None]) + + # Go through each member in the server and, based on what roles they should have because of the + # events they are in, remove any roles they shouldn't have + for member in members: + should_have_roles = set() + for room in all_rooms: + if room.check_player( + member) is not None or member.id in room.subs: + should_have_roles.add(room.room_role) + should_not_have_roles = set(tier_roles) - should_have_roles + member_roles = set(member.roles) + roles_to_remove = should_not_have_roles.intersection( + member_roles) + if len(roles_to_remove) > 0: + try: + await member.remove_roles(*roles_to_remove) + except BaseException: + print(traceback.format_exc()) + + except Exception as e: + print(traceback.format_exc()) + + @tasks.loop(minutes=3) + async def check_room_threads_task(self): + try: + if self.ongoing_event is not None: + await self.check_room_threads(self.ongoing_event) + except BaseException: + 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): + @tasks.loop(minutes=30) + async def refresh_ratings(self): + """Refreshes the ratings""" + try: + await self.ratings.update_ratings() + except Exception as e: + print(traceback.format_exc()) + + 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}" + + async def print_vote_button_info(self, interaction): + str = "" + + str += f"The new amount of players per room is {self.PLAYERS_PER_ROOM}\n" + str += f"The new vote buttons for this amount of players per room are " + + format_names = [btn["format_name"] + for btn in self.ALLOWED_VOTE_BUTTONS] + str += ", ".join(format_names) + + self.dump_staff_settings() + self.ongoing_event.buttons = self.ALLOWED_VOTE_BUTTONS + + await interaction.response.send_message(str) + + @app_commands.command(name="update_players_per_room") + @app_commands.guild_only() + async def update_players_per_room(self, interaction: discord.Interaction, new_amount: Range[int, 2]): + """Sets the new amount of player per room for queue. Staff use only.""" + self.PLAYERS_PER_ROOM = new_amount + self.ALLOWED_VOTE_BUTTONS = SquadQueue.generate_vote_buttons( + self.PLAYERS_PER_ROOM) + + common.CONFIG["PLAYERS_PER_ROOM"] = new_amount + + self.ongoing_event.players_per_room = new_amount + await self.print_vote_button_info(interaction) + + @staticmethod + def generate_vote_buttons(player_amount): + new_vote_button_list = [] + iterator = iter(range(1, player_amount)) + + for i in iterator: + if player_amount % i == 0: + format_name = "FFA" if i == 1 else f"{i}v{i}" + new_vote_button_list.append({ + "num_players_per_team": i, + "format_name": format_name + }) + + return new_vote_button_list + + async def add_vote_button_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + total = self.PLAYERS_PER_ROOM + + existing_formats = {btn["format_name"] + for btn in self.ALLOWED_VOTE_BUTTONS} + + new_formats = [] + for i in range(1, total): + if total % i != 0: + continue + fmt = "FFA" if i == 1 else f"{i}v{i}" + if fmt not in existing_formats and current in fmt: + new_formats.append(fmt) + + return [ + app_commands.Choice(name=fmt, value=fmt) + for fmt in new_formats[:25] + ] + + @app_commands.command(name="add_vote_button") + @app_commands.autocomplete(vote_string=add_vote_button_autocomplete) + @app_commands.guild_only() + async def add_vote_button(self, interaction: discord.Interaction, vote_string: str): + """Adds a vote button from list of possible buttons. Staff use only.""" + if vote_string == "FFA": + num_players_per_team = 1 + else: + try: + num_players_per_team = int(vote_string.split("v")[0]) + except (ValueError, IndexError): + await interaction.response.send_message("Invalid format name.", ephemeral=True) + return + + if self.PLAYERS_PER_ROOM % num_players_per_team != 0: + await interaction.response.send_message( + f"Invalid team size. `{num_players_per_team}` must divide evenly into `{self.PLAYERS_PER_ROOM}`.", + ephemeral=True + ) + return + + self.ALLOWED_VOTE_BUTTONS.append({ + "num_players_per_team": num_players_per_team, + "format_name": vote_string + }) + + self.ALLOWED_VOTE_BUTTONS.sort(key=lambda x: x["num_players_per_team"]) + + await self.print_vote_button_info(interaction) + + async def remove_vote_button_autocomplete(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: + choices = [ + app_commands.Choice( + name=btn["format_name"], value=btn["format_name"]) + for btn in self.ALLOWED_VOTE_BUTTONS + if current in btn["format_name"] + ] + return choices[:25] + + @app_commands.command(name="remove_vote_button") + @app_commands.autocomplete(vote_string=remove_vote_button_autocomplete) + @app_commands.guild_only() + async def remove_vote_button(self, interaction: discord.Interaction, vote_string: str): + """Removes a vote button from list of possible buttons. Staff use only.""" + if len(self.ALLOWED_VOTE_BUTTONS) <= 1: + await interaction.response.send_message("You cannnot remove last possible button.") + return + + self.ALLOWED_VOTE_BUTTONS = [ + btn for btn in self.ALLOWED_VOTE_BUTTONS if btn["format_name"] != vote_string + ] + + await self.print_vote_button_info(interaction) @commands.command(name="sync") @commands.is_owner() @@ -874,23 +2248,68 @@ 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 + if not self.is_production: + if common.SERVER is common.Server.MK8DX: + member = await self.bot.fetch_user(318637887597969419) + elif common.SERVER is common.Server.MKW: + member = await self.bot.fetch_user(82862780591378432) + elif common.SERVER is common.Server.MKWorld: + member = await self.bot.fetch_user(318637887597969419) + check_players = [member] check_players.extend(members) - players = await mk8dx_150cc_mmr(self.URL, check_players) - for i in range(0, 12): - player = Player( - players[0].member, f"{players[0].lounge_name}{i + 1}", players[0].mmr + (10 * i)) + players = self.ratings.get_rating(check_players) + player_member = member + player_name = member.display_name + player_mmr = 5000 + if len(players) > 0 and players[0]: + player_member = players[0].member + player_name = players[0].lounge_name + player_mmr = players[0].mmr + + for i in range(0, self.PLAYERS_PER_ROOM): + player = Player(player_member, + f"{player_name}{i + 1}", + player_mmr + (10 * i)) player.confirmed = True squad = Team([player]) mogi.teams.append(squad) - msg = f"{players[0].lounge_name} added 12 times." + msg = f"{player_name} added {self.PLAYERS_PER_ROOM} times." + await self.queue_or_send(ctx, msg) + if await self.check_close_event_change(): + await self.launch_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 + member = ctx.author + if not self.is_production: + if common.SERVER is common.Server.MK8DX: + member = await self.bot.fetch_user(318637887597969419) + elif common.SERVER is common.Server.MKW: + member = await self.GUILD.fetch_member(1114699357179088917) + elif common.SERVER is common.Server.MKWorld: + member = await self.bot.fetch_user(318637887597969419) + for i, rating in enumerate(ratings, 1): + if common.is_int(rating): + player = Player(member, f"{member.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) + if await self.check_close_event_change(): + await self.launch_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,35 +2317,40 @@ 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 + if not self.is_production: + if common.SERVER is common.Server.MK8DX: + member = await self.bot.fetch_user(318637887597969419) + elif common.SERVER is common.Server.MKW: + member = await self.bot.fetch_user(314861232693706752) + elif common.SERVER is common.Server.MKWorld: + member = await self.bot.fetch_user(318637887597969419) + check_players = [member] check_players.extend(members) - players = await mk8dx_150cc_mmr(self.URL, check_players) - for i in range(0, 100): - player = Player( - players[0].member, f"{players[0].lounge_name}{i + 1}", players[0].mmr + (10 * i)) + players = self.ratings.get_rating(check_players) + 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) + if await self.check_close_event_change(): + await self.launch_mogi() @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..70aa362 --- /dev/null +++ b/common.py @@ -0,0 +1,50 @@ +import json +from enum import Enum, auto +from typing import TypeVar, Type, List +import config_checker + +_T = TypeVar('_T') + +with open('./config.json', 'r') as cjson: + CONFIG = json.load(cjson) + + +class Server(Enum): + MKW = auto() + MK8DX = auto() + MKWorld = auto() + + +if CONFIG["lounge"] == "MKW": + SERVER = Server.MKW +elif CONFIG["lounge"] == "MK8DX": + SERVER = Server.MK8DX +elif CONFIG["lounge"] == "MKWorld": + SERVER = Server.MKWorld +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 + + +def is_int(var: str) -> bool: + try: + int(var) + return True + except ValueError: + return False + + +config_checker.check(CONFIG) diff --git a/config_checker.py b/config_checker.py new file mode 100644 index 0000000..0e03128 --- /dev/null +++ b/config_checker.py @@ -0,0 +1,103 @@ + +STRONGLY_REQUIRED_FIELDS = [ + ("token", str), + ("is_production", bool), + ("lounge", str), + ("lounge_description", str), + ("guild_id", int), + ("queue_join_channel", int), + ("queue_sub_channel", int), + ("queue_list_channel", int), + ("queue_history_channel", int), + ("queue_general_channel", int), + ("queue_schedule_channel", (int, type(None))), + ("admin_roles", list), + ("admin_roles.list_items", int), + ("helper_staff_roles", list), + ("helper_staff_roles.list_items", int), + ("members_for_channels", list), + ("members_for_channels.list_items", (int, bool)), + ("roles_for_channels", list), + ("roles_for_channels.list_items", int), + ("PLACEMENT_PLAYER_MMR", int), + ("placement_role_id", int), + ("frequently_tagged_role_id", int), + ("restricted_role_id", int), + ("muted_role_id", int), + ("queue_messages", bool), + ("sec_between_queue_msgs", int), + ("username", str), + ("password", str), + ("url", str), + ("track_type", str), + ("FIRST_EVENT_TIME", int), + ("FIRST_EVENT_TIME_DESCRIPTION", str), + ("FORCED_FORMAT_ORDER", list), + ("FORCED_FORMAT_ORDER_OFFSET", int), + ("FORCED_FORMAT_HOURLY_OFFSET", int), + ("FORCED_FORMAT_AUTOSCHEDULE_AMOUNT", int), + ("QUEUES_BETWEEN_FORCED_FORMAT_QUEUE", (int, type(None))), + ("JOINING_TIME", int), + ("QUEUE_OPEN_TIME", int), + ("DISPLAY_OFFSET_MINUTES", int), + ("EXTENSION_TIME", int), + ("ROOM_JOIN_PENALTY_TIME", int), + ("MOGI_LIFETIME", int), + ("SUB_RANGE_MMR_ALLOWANCE", int), + ("SUB_MESSAGE_LIFETIME_SECONDS", int), + ("ROOM_MMR_THRESHOLD", int), + ("MATCHMAKING_BOTTOM_MMR", (int, type(None))), + ("MATCHMAKING_TOP_MMR", (int, type(None))), + ("USE_THREADS", bool) +] + + +def check(config: dict): + for required_field_name, required_field_type in STRONGLY_REQUIRED_FIELDS: + check_list_items = required_field_name.endswith(".list_items") + if check_list_items: + required_field_name = required_field_name[:-len(".list_items")] + + if required_field_name not in config: + raise ValueError( + f"Did not find field '{required_field_name}' in config. Your config appears to be out of date.") + field_data = config[required_field_name] + if not isinstance(required_field_type, tuple): + required_field_type = (required_field_type,) + if check_list_items: + for item in field_data: + if type(item) not in required_field_type: + raise TypeError( + f"""For field '{required_field_name}', expected items to be of type {" or ".join([str(t) for t in required_field_type])}, but items in config were of type {type(item)}""") + else: + + if type(field_data) not in required_field_type: + raise TypeError( + f"""For field '{required_field_name}', expected type {" or ".join([str(t) for t in required_field_type])}, but config type is {type(field_data)}""") + + # Validate tier channels data if needed + if not config["USE_THREADS"]: + required_field_name = "TIER_CHANNELS" + if required_field_name not in config: + raise ValueError( + f"Did not find field '{required_field_name}' in config. Your config appears to be out of date.") + tier_channel_data = config[required_field_name] + if not isinstance(tier_channel_data, dict): + raise TypeError( + f"""For field '{required_field_name}', expected type dict, but config type is {type(tier_channel_data)}""") + for k, v in tier_channel_data.items(): + if not isinstance(k, str): + raise TypeError( + f"""For field '{required_field_name}', the keys must all be of type str, but a key of type {type(k)} was found.""") + needed_keys = [("tier_role_id", int), ("channel_ids", + list), ("role_ids_can_see_already", list)] + for sub_k, sub_type in needed_keys: + if sub_k not in v: + raise ValueError( + f"Did not find field '{sub_k}' in each item of TIER_CHANNELS in the config. Your config appears to be out of date.") + sub_data = v[sub_k] + if not isinstance(sub_data, sub_type): + raise TypeError( + f"""For field '{sub_k}' in TIER_CHANNELS data, found type {type(sub_data)}, expected {sub_type}.""") + + print("Config file validated.") diff --git a/example_configs/prod/mk8dx_prod_config.json b/example_configs/prod/mk8dx_prod_config.json new file mode 100644 index 0000000..8d5c918 --- /dev/null +++ b/example_configs/prod/mk8dx_prod_config.json @@ -0,0 +1,56 @@ +{ + "token": "", + "is_production": true, + "lounge": "MK8DX", + "lounge_description": "Valid options are MKW, MKWorld, 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, + "queue_schedule_channel": 1274492585917812807, + "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://lounge.mkcentral.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.", + "FORCED_FORMAT_ORDER": ["FFA", "3v3", "FFA", "4v4"], + "FORCED_FORMAT_ORDER_OFFSET": 3, + "FORCED_FORMAT_HOURLY_OFFSET": 0, + "FORCED_FORMAT_AUTOSCHEDULE_AMOUNT": 1, + "QUEUES_BETWEEN_FORCED_FORMAT_QUEUE": 9, + "QUEUE_OPEN_TIME": 60, + "JOINING_TIME": 55, + "DISPLAY_OFFSET_MINUTES": 5, + "EXTENSION_TIME": 3, + "ROOM_JOIN_PENALTY_TIME": 6, + "MOGI_LIFETIME": 180, + "SUB_RANGE_MMR_ALLOWANCE": 500, + "SUB_MESSAGE_LIFETIME_SECONDS": 1200, + "ROOM_MMR_THRESHOLD": 9999999, + "MATCHMAKING_BOTTOM_MMR": null, + "MATCHMAKING_TOP_MMR": null, + "USE_THREADS": true +} \ 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..bee8bf9 --- /dev/null +++ b/example_configs/prod/mkw_ct_prod_config.json @@ -0,0 +1,67 @@ +{ + "token": "", + "is_production": true, + "lounge": "MKW", + "lounge_description": "Valid options are MKW, MKWorld, 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, + "queue_schedule_channel": null, + "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": 5, + "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.", + "FORCED_FORMAT_ORDER": [], + "FORCED_FORMAT_ORDER_OFFSET": 0, + "FORCED_FORMAT_HOURLY_OFFSET": 0, + "FORCED_FORMAT_AUTOSCHEDULE_AMOUNT": 1, + "QUEUES_BETWEEN_FORCED_FORMAT_QUEUE": null, + "QUEUE_OPEN_TIME": 80, + "JOINING_TIME": 75, + "DISPLAY_OFFSET_MINUTES": 0, + "EXTENSION_TIME": 5, + "ROOM_JOIN_PENALTY_TIME": 8, + "MOGI_LIFETIME": 150, + "SUB_RANGE_MMR_ALLOWANCE": 700, + "SUB_MESSAGE_LIFETIME_SECONDS": 1200, + "ROOM_MMR_THRESHOLD": 9000, + "MATCHMAKING_BOTTOM_MMR": null, + "MATCHMAKING_TOP_MMR": null, + "USE_THREADS": true +} \ 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..338f7d3 --- /dev/null +++ b/example_configs/prod/mkw_rt_prod_config.json @@ -0,0 +1,120 @@ +{ + "token": "", + "is_production": true, + "lounge": "MKW", + "lounge_description": "Valid options are MKW, MKWorld, 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, + "queue_schedule_channel": null, + "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": -55, + "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.", + "FORCED_FORMAT_ORDER": [], + "FORCED_FORMAT_ORDER_OFFSET": 0, + "FORCED_FORMAT_HOURLY_OFFSET": 0, + "FORCED_FORMAT_AUTOSCHEDULE_AMOUNT": 1, + "QUEUES_BETWEEN_FORCED_FORMAT_QUEUE": null, + "QUEUE_OPEN_TIME": 72, + "JOINING_TIME": 67, + "DISPLAY_OFFSET_MINUTES": 0, + "EXTENSION_TIME": 5, + "ROOM_JOIN_PENALTY_TIME": 8, + "MOGI_LIFETIME": 120, + "SUB_RANGE_MMR_ALLOWANCE": 700, + "SUB_MESSAGE_LIFETIME_SECONDS": 1200, + "ROOM_MMR_THRESHOLD": 8000, + "MATCHMAKING_BOTTOM_MMR": -2000, + "MATCHMAKING_TOP_MMR": 11000, + "USE_THREADS": false, + "TIER_CHANNELS": { + "HT": { + "tier_role_id": 1264625004637720656, + "channel_ids": [ + 389456664232984577, + 830217477291114516, + 387361702829883392, + 805889218516877372, + 841730455422107669, + 1264728881253060708 + ], + "role_ids_can_see_already": [ + 871740024155168829, + 871739972313575465, + 871739726065963008, + 871739761574969384 + ] + }, + "MT": { + "tier_role_id": 1264625254752583803, + "channel_ids": [ + 862105563701968906, + 953805745994997761, + 722454335287590963, + 746216972869435402, + 1265451963236094044, + 1265452192371048548 + ], + "role_ids_can_see_already": [ + 871739761574969384, + 871739875383181333, + 871739925794525185 + ] + }, + "LT": { + "tier_role_id": 1264625310079385731, + "channel_ids": [ + 389456944181674004, + 581228135651999744, + 746458027359469700, + 387361809587240960, + 389457003820482571, + 1264728728920129597 + ], + "role_ids_can_see_already": [ + 871740123182690314, + 871740169290674236, + 950076470221504653, + 723753340063842345 + ] + } + + } +} \ No newline at end of file diff --git a/example_configs/prod/mkworld_prod_config.json b/example_configs/prod/mkworld_prod_config.json new file mode 100644 index 0000000..95580c4 --- /dev/null +++ b/example_configs/prod/mkworld_prod_config.json @@ -0,0 +1,57 @@ +{ + "token": "", + "is_production": true, + "lounge": "MKWorld", + "lounge_description": "Valid options are MKW, MKWorld, or MK8DX", + "guild_id": 1326380310233092157, + "queue_join_channel": 1349069297955242038, + "queue_sub_channel": 1349069321942339664, + "queue_list_channel": 1349069264157671564, + "queue_history_channel": 1349069358554677380, + "queue_general_channel": 1349069297955242038, + "queue_schedule_channel": 1349069338237206589, + "admin_roles": [ + 1326381661558014086, + 1326382705969205341 + ], + "helper_staff_roles": [ + 1326381688900812800 + ], + "members_for_channels": [ + 934740707934081035, + 743532508343304242, + 735910635082219570 + ], + "roles_for_channels": [], + "PLAYERS_PER_ROOM": 24, + "PLACEMENT_PLAYER_MMR": 5000, + "placement_role_id": 1381068401908252833, + "frequently_tagged_role_id": 1382218727445102653, + "restricted_role_id": 1381068109359616050, + "muted_role_id": 1349068927489278074, + "queue_messages": true, + "sec_between_queue_msgs": 2, + "username": "username", + "password": "password", + "url": "https://lounge.mkcentral.com/", + "track_type": "rt", + "FIRST_EVENT_TIME": -12, + "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.", + "FORCED_FORMAT_ORDER": ["FFA"], + "FORCED_FORMAT_ORDER_OFFSET": 0, + "FORCED_FORMAT_HOURLY_OFFSET": 0, + "FORCED_FORMAT_AUTOSCHEDULE_AMOUNT": 24, + "QUEUES_BETWEEN_FORCED_FORMAT_QUEUE": 1, + "QUEUE_OPEN_TIME": 72, + "JOINING_TIME": 67, + "DISPLAY_OFFSET_MINUTES": 5, + "EXTENSION_TIME": 3, + "ROOM_JOIN_PENALTY_TIME": 6, + "MOGI_LIFETIME": 180, + "SUB_RANGE_MMR_ALLOWANCE": 500, + "SUB_MESSAGE_LIFETIME_SECONDS": 1200, + "ROOM_MMR_THRESHOLD": 9999999, + "MATCHMAKING_BOTTOM_MMR": null, + "MATCHMAKING_TOP_MMR": null, + "USE_THREADS": true +} \ No newline at end of file diff --git a/host_fcs.py b/host_fcs.py new file mode 100644 index 0000000..18a243b --- /dev/null +++ b/host_fcs.py @@ -0,0 +1,50 @@ +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_mkworld_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) + elif common.SERVER is common.Server.MKWorld: + return await _get_mkworld_hosts(discord_ids) + else: + raise ValueError("Bad config settings for server.") diff --git a/lounge.py b/lounge.py index 5fe2f82..68c2f41 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,14 @@ 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..bd7a1b1 100644 --- a/mmr.py +++ b/mmr.py @@ -1,69 +1,255 @@ import aiohttp -import discord from mogi_objects import Player +import common +import discord +from typing import List +import asyncio +import traceback headers = {'Content-type': 'application/json'} -async def mk8dx_150cc_mmr(url, members): - base_url = url + '/api/player?' - 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"discordId={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 - player_data = await resp.json() - if 'mmr' not in player_data.keys(): - players.append( - Player(member, player_data['name'], None)) - continue - players.append( - Player(member, player_data['name'], player_data['mmr'])) - except: - print(f"Fetch for player {members[0]} has failed.", flush=True) - return players - - -async def get_mmr_from_discord_id(discord_id): - base_url = "https://www.mk8dx-lounge.com" + '/api/player?' - timeout = aiohttp.ClientTimeout(total=10) - async with aiohttp.ClientSession( - timeout=timeout, - auth=aiohttp.BasicAuth( - "username", "password")) as session: - request_text = f"discordId={discord_id}" - request_url = base_url + request_text - async with session.get(request_url, headers=headers) as resp: - if resp.status != 200: - return "Player does not exist" - player_data = await resp.json() - if 'mmr' not in player_data.keys(): - return "Player has no mmr" - 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 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'] +class RatingsNotReady(Exception): + pass + + +class RatingRequestFailure(Exception): + pass + + +class BadRatingData(RatingRequestFailure): + pass + + +class BadPlayerDataLength(BadRatingData): + pass + + +class BadPlayerData(BadRatingData): + pass + + +class Ratings: + def __init__(self): + self.first_run_complete = False + self.ratings = {} + + async def update_ratings(self): + if common.SERVER is common.Server.MK8DX: + rating_func = self._pull_mk8dx_ratings + elif common.SERVER is common.Server.MKW: + rating_func = self._pull_mkw_ratings + elif common.SERVER is common.Server.MKWorld: + rating_func = self._pull_mkworld_ratings + else: + raise Exception("Unreachable code.") + + status = await rating_func() + # If we failed to pull ratings, wait 60 seconds and try again + if not status: + print( + f"Failed to pull ratings for {common.SERVER.name}. Waiting 60 seconds and trying again.") + await asyncio.sleep(60) + status = await rating_func() + # If we failed to pull ratings again, fail. + if not status: + print( + f"Failed to pull ratings for {common.SERVER.name} on 2nd attempt. Skipping.") + return + self.first_run_complete = True + + async def _pull_mk8dx_ratings(self) -> bool: + url = f"""{common.CONFIG["url"]}/api/player/list?game=mk8dx""" + return await Ratings._pull_ratings(url, self._parse_mk8dx_ratings, self._validate_mk8dx_response) + + async def _pull_mkw_ratings(self) -> bool: + url = f"""{common.CONFIG["url"]}/api/ladderplayer.php?ladder_type={common.CONFIG["track_type"]}&all&fields=discord_user_id,current_mmr,player_name""" + return await Ratings._pull_ratings(url, self._parse_mkw_ratings, self._validate_mkw_response) + + async def _pull_mkworld_ratings(self) -> bool: + url = f"""{common.CONFIG["url"]}/api/player/list?game=mkworld""" + return await Ratings._pull_ratings(url, self._parse_mk8dx_ratings, self._validate_mkworld_response) + + @staticmethod + async def _pull_ratings(url, parser, validator) -> bool: + """Returns True if the ratings were pulled and successfully stored.""" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status != 200: + print(f"{common.SERVER.name} returned status {response.status}") + return False + data = await response.json() + try: + validator(data) + except RatingRequestFailure: + print( + f"{common.SERVER.name}'s data from API was formatted incorrectly.") + print(traceback.format_exc()) + return False + parser(data) + return True + return False # Didn't run request successfully, so we return it was a failure + + def _validate_mk8dx_response(self, results: dict): + if not isinstance(results, dict): + raise BadRatingData("Response is not a dictionary") + all_players = results.get("players") + if all_players is None: + raise BadRatingData( + "Key word 'players' not found in JSON response.") + # + required_player_amount = 10000 + if len(all_players) < required_player_amount: + raise BadPlayerDataLength( + f"Not enough players found in the JSON response. Required {required_player_amount} players in JSON response, only found {len(all_players)} players in JSON response." + "") + + strongly_required_fields = [("name", str)] + weakly_required_fields = [("discordId", str), ("mmr", int)] + for player in all_players: + # Ensure all strongly required fields are in the player JSON and + # that the type is correct + for strongly_req_field_name, strongly_req_field_type in strongly_required_fields: + if strongly_req_field_name not in player: + raise BadPlayerData( + f"Missing required field '{strongly_req_field_name}' in the following player: {player}") + field_data = player[strongly_req_field_name] + if not isinstance(field_data, strongly_req_field_type): + raise BadPlayerData( + f"For field '{strongly_req_field_name}', expected type '{strongly_req_field_type}' received {type(field_data)} for player: {player}") + # Ensure that if the weakly required field is in the JSON, the type + # is correct + for weakly_req_field_name, weakly_req_field_type in weakly_required_fields: + if weakly_req_field_name in player: + field_data = player[weakly_req_field_name] + if not isinstance(field_data, weakly_req_field_type): + raise BadPlayerData( + f"For field '{weakly_req_field_name}', expected type '{weakly_req_field_type}' received {type(field_data)} for player: {player}") + + def _parse_mk8dx_ratings(self, results: dict): + self.ratings.clear() + all_players = results.get("players") + for player in all_players: + discord_id = player.get("discordId") + if discord_id is None: + continue + rating = common.CONFIG["PLACEMENT_PLAYER_MMR"] if player.get( + "mmr") is None else player.get("mmr") + self.ratings[discord_id] = (rating, player["name"]) + + def _validate_mkworld_response(self, results: dict): + if not isinstance(results, dict): + raise BadRatingData("Response is not a dictionary") + all_players = results.get("players") + if all_players is None: + raise BadRatingData( + "Key word 'players' not found in JSON response.") + # + required_player_amount = 100 + if len(all_players) < required_player_amount: + raise BadPlayerDataLength( + f"Not enough players found in the JSON response. Required {required_player_amount} players in JSON response, only found {len(all_players)} players in JSON response." + "") + + strongly_required_fields = [("name", str)] + weakly_required_fields = [("discordId", str), ("mmr", int)] + for player in all_players: + # Ensure all strongly required fields are in the player JSON and + # that the type is correct + for strongly_req_field_name, strongly_req_field_type in strongly_required_fields: + if strongly_req_field_name not in player: + raise BadPlayerData( + f"Missing required field '{strongly_req_field_name}' in the following player: {player}") + field_data = player[strongly_req_field_name] + if not isinstance(field_data, strongly_req_field_type): + raise BadPlayerData( + f"For field '{strongly_req_field_name}', expected type '{strongly_req_field_type}' received {type(field_data)} for player: {player}") + # Ensure that if the weakly required field is in the JSON, the type + # is correct + for weakly_req_field_name, weakly_req_field_type in weakly_required_fields: + if weakly_req_field_name in player: + field_data = player[weakly_req_field_name] + if not isinstance(field_data, weakly_req_field_type): + raise BadPlayerData( + f"For field '{weakly_req_field_name}', expected type '{weakly_req_field_type}' received {type(field_data)} for player: {player}") + + def _validate_mkw_response(self, results: dict): + if not isinstance(results, dict): + raise BadRatingData("Response is not a dictionary") + mkw_status = results.get("status") + if mkw_status is None: + raise BadRatingData( + "Key word 'status' not found in JSON response.") + if mkw_status != "success": + raise BadRatingData( + f"'status' had value of {mkw_status} in JSON response.") + + all_players = results.get("results") + if all_players is None: + raise BadRatingData( + "Key word 'results' not found in JSON response.") + # + required_player_amount = 1000 + if len(all_players) < required_player_amount: + raise BadPlayerDataLength( + f"Not enough players found in the JSON response. Required {required_player_amount} players in JSON response, only found {len(all_players)} players in JSON response." + "") + + for player in all_players: + if "player_name" not in player: + raise BadPlayerData( + f"Missing required field 'player_name' in the following player: {player}") + if "discord_user_id" not in player: + raise BadPlayerData( + f"Missing required field 'discord_user_id' in the following player: {player}") + if "current_mmr" not in player: + raise BadPlayerData( + f"Missing required field 'current_mmr' in the following player: {player}") + player_name = player.get("player_name") + discord_user_id = player.get("discord_user_id") + current_mmr = player.get("current_mmr") + if not ( + isinstance( + discord_user_id, + str) or discord_user_id is None): + raise BadPlayerData( + f"For field 'discord_user_id', expected type 'str' or None, received {type(discord_user_id)} for player: {player}") + if not isinstance(current_mmr, int): + raise BadPlayerData( + f"For field 'current_mmr', expected type 'int' received {type(current_mmr)} for player: {player}") + if not isinstance(player_name, str): + raise BadPlayerData( + f"For field 'player_name', expected type 'str' received {type(player_name)} for player: {player}") + + def _parse_mkw_ratings(self, results: dict): + self.ratings.clear() + all_players = results.get("results") + for player in all_players: + discord_user_id = player.get("discord_user_id") + if discord_user_id is not None: + self.ratings[player["discord_user_id"]] = ( + player["current_mmr"], player["player_name"]) + + def get_rating_from_discord_id(self, discord_id: int | str) -> int | None: + discord_id = str(discord_id) + if not self.first_run_complete: + raise RatingsNotReady("Ratings not pulled yet.") + if discord_id in self.ratings: + return self.ratings.get(discord_id)[0] + # Make clear that we intend to return None by explicitly doing so + else: + return None + + def get_rating( + self, members: List[discord.User | discord.Member]) -> List[Player]: + if not self.first_run_complete: + raise RatingsNotReady("Ratings not pulled yet.") + all_players = [] + for member in members: + # Are discord member IDs already strings...? + member_id = str(member.id) + if member_id not in self.ratings: + continue + rating, name = self.ratings[member_id] + all_players.append(Player(member, name, rating)) + return all_players diff --git a/mogi_objects.py b/mogi_objects.py index 7a9c1a8..3bc22e0 100644 --- a/mogi_objects.py +++ b/mogi_objects.py @@ -1,24 +1,256 @@ +from __future__ import annotations + +import traceback +import common +from contextlib import suppress +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, Set +import host_fcs + + +class PrepFailure(Exception): + pass + + +class WrongChannelType(TypeError, PrepFailure): + pass + + +class RoleFailure(PrepFailure): + pass + + +class RoleAddFailure(RoleFailure): + pass + + +class RoleNotFound(RoleFailure): + pass + + +class NoFreeChannels(PrepFailure): + pass + + +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 + ALGORITHM_STATUS_SUCCESS_SOME_INVALID = 5 + + def __init__( + self, + sq_id: int, + max_players_per_team: int, + players_per_room: int, + buttons, + mogi_channel: discord.TextChannel, + is_automated=False, + start_time=None, + display_time=None, + additional_extension_minutes=0, + format=None): 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.buttons = buttons 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) + self.format = format + self.has_checked_auto_extend = False + + @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].adjusted_mmr - \ + best_collection[0].adjusted_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.adjusted_mmr - lowest_player.adjusted_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 _all_room_final_list_algorithm(self, valid_players_check: Callable[[ + List[Player]], bool]) -> Tuple[List[List[Player]], int]: + if self.max_possible_rooms == 0: + return [], Mogi.ALGORITHM_STATUS_INSUFFICIENT_PLAYERS + all_confirmed_players = self.players_on_confirmed_teams() + first_late_player_index = ( + self.num_players // self.players_per_room) * self.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:] + player_rooms: List[List[Player]] = list( + common.divide_chunks(on_time_players, self.players_per_room)) + + any_invalid = False + for pr_index, player_room in enumerate(player_rooms, 0): + for lp_index in range(len(late_players) + 1): + current_late_check_players = late_players[0:lp_index] + best_collection = Mogi._minimize_range( + player_room + current_late_check_players, self.players_per_room) + if valid_players_check(best_collection): + best_collection.sort(reverse=True) + # Get all the players who were swapped out of the room and + # put them at the front of the late player list + swapped_out_players = [ + p for p in player_room if p not in best_collection] + swapped_in_players = [ + p for p in best_collection if p not in player_room] + late_players = swapped_out_players + \ + list( + filter(lambda p: p not in swapped_in_players, late_players)) + player_rooms[pr_index] = best_collection + break + else: + # After checking all late players, no room player list with a + # valid range could be found for this room + any_invalid = True + + return player_rooms, ( + Mogi.ALGORITHM_STATUS_SUCCESS_SOME_INVALID if any_invalid else Mogi.ALGORITHM_STATUS_SUCCESS_FOUND) + + 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 sorted(confirmed_players[0:self.players_per_room * + self.max_possible_rooms], reverse=True), 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 sorted( + best_collection, reverse=True), 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 best_collection, Mogi.ALGORITHM_STATUS_SUCCESS_EMPTY + + def _mk8dx_generate_final_list(self) -> List[Player]: + confirmed_players = self.players_on_confirmed_teams() + if self.max_possible_rooms == 0: + return confirmed_players + return sorted( + confirmed_players[0:self.players_per_room * self.max_possible_rooms], reverse=True) + + def _mkw_generate_final_list(self, valid_players_check: Callable[[ + List[Player]], bool]) -> List[Player]: + result, status = self._all_room_final_list_algorithm( + valid_players_check) + if status is Mogi.ALGORITHM_STATUS_INSUFFICIENT_PLAYERS: + return self.players_on_confirmed_teams() + # I do not want to formally prove that sorting here is correct + return sorted(common.flatten(result), reverse=True) + + 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) + elif common.SERVER is common.Server.MKWorld: + return self._mk8dx_generate_final_list() else: - self.start_time = start_time + raise ValueError(f"Unknown server in config: {common.Server}") + + def _count_cancelled(self, + player_list: List[Player], + valid_players_check: Callable[[List[Player]], + bool] = None) -> int: + """Using the provided valid players check function and player list, returns the number of rooms that would be cancelled.""" + if valid_players_check is None: + return 0 + return sum( + 1 for room_players in common.divide_chunks( + player_list, + self.players_per_room) if not valid_players_check(room_players)) + + def _any_cancelled(self, + player_list: List[Player], + valid_players_check: Callable[[List[Player]], + bool] = None) -> bool: + return self._count_cancelled(player_list, valid_players_check) > 0 + + def any_room_cancelled(self, valid_players_check: Callable[[ + List[Player]], bool] = None) -> bool: + """Using the provided valid players check function, returns if any of the rooms will be cancelled.""" + if valid_players_check is None: + return False + proposed_list = self.generate_proposed_list(valid_players_check) + return self._any_cancelled(proposed_list, valid_players_check) def check_player(self, member): for team in self.teams: @@ -26,111 +258,250 @@ 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: - if room.thread.id == channel_id: - return True - return False + def all_room_channel_ids(self) -> Set[int]: + return { + r.channel.id for r in self.rooms if r is not None and r.channel is not None} + + def channel_id_in_rooms(self, channel_id: int): + return channel_id in self.all_room_channel_ids() - def get_room_from_thread(self, channel_id: int): + def get_room_from_channel_id(self, channel_id: int): for room in self.rooms: - if room.thread.id == channel_id: + if room is None or room.channel is None: + continue + if room.channel.id == channel_id: 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 + + async def assign_roles(self, guild: discord.Guild): + for room in self.rooms: + try: + to_skip = None if room.room_role is None else { + room.room_role.id} + await room.assign_roles(guild=guild, role_skip=to_skip) + except RoleAddFailure: + pass + class Room: - def __init__(self, teams, room_num: int, thread: discord.Thread): - self.teams = teams + def __init__( + self, + teams, + room_num: int, + channel: discord.Thread | discord.TextChannel, + tier_info): + 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.channel = channel self.view = None self.finished = False + self.host_list: List["Player"] = [] + self.subs: List[int] = [] + self.tier_info = tier_info + self.room_role = None + + @property + def tier(self) -> str: + if common.SERVER is common.Server.MK8DX: + return get_tier_mk8dx(round(self.avg_mmr) - 500) + elif common.SERVER is common.Server.MKW: + return str(get_tier_mkw(self.avg_mmr, self.players)) + elif common.SERVER is common.Server.MKWorld: + return get_tier_mkworld(round(self.avg_mmr) - 500) + + @property + def tier_collection(self) -> str: + if common.SERVER is common.Server.MK8DX: + return self.tier + elif common.SERVER is common.Server.MKW: + tier_number = self.tier + if not common.is_int(tier_number): + return "HT" + tier_number = int(tier_number) + if tier_number <= 2: + return "LT" + if tier_number >= 5: + return "HT" + return "MT" + elif common.SERVER is common.Server.MKWorld: + return self.tier + + @property + def mmr_high(self) -> int: + if self.teams is None: + return None + return max(self.players).adjusted_mmr + + @property + def mmr_low(self) -> int: + if self.teams is None: + return None + return min(self.players).adjusted_mmr + + @property + def avg_mmr(self) -> int: + if self.teams is None: + return 0 + return int(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 + + def check_player(self, member): + if self.teams is None: + return None + for team in self.teams: + if team.has_player(member): + return team + return None + + async def assign_member_room_role(self, member: discord.Member): + if self.room_role is None: + raise RoleNotFound(f"Could not find role for tier {self.tier}") + try: + await member.add_roles(self.room_role) + except BaseException: + raise RoleAddFailure( + f"Failed to add role {self.room_role.name} to {member}\n") + + async def assign_roles(self, guild: discord.Guild, role_skip=None): + role_add_fail_text = "" + role_skip = set() if role_skip is None else set(role_skip) + for player in self.players: + updated_member = guild.get_member(player.member.id) + if updated_member is not None: + member_role_ids = {r.id for r in updated_member.roles} + if len(role_skip.intersection(member_role_ids)) > 0: + continue + try: + await self.assign_member_room_role(player.member) + except RoleAddFailure: + role_add_fail_text += f"Failed to add role {self.room_role.name} to {player.member}\n" + print(traceback.format_exc()) + + if role_add_fail_text != "": + await self.channel.send(role_add_fail_text) + raise RoleAddFailure(role_add_fail_text) + + async def prepare_room_channel(self, guild: discord.Guild, all_events: List[Mogi | None]): + if common.CONFIG["USE_THREADS"]: + return + + # Find the available tier channels for the tier (collection) + tier_collection = self.tier_collection + tier_data = common.CONFIG["TIER_CHANNELS"][tier_collection] + free_tier_channel_ids = list(tier_data["channel_ids"]) + for event in all_events: + if event is None: + continue + for channel_id in event.all_room_channel_ids(): + with suppress(ValueError): + free_tier_channel_ids.remove(channel_id) + # If there are no available tier channels, raise an exception + if len(free_tier_channel_ids) == 0: + raise NoFreeChannels( + f"No free channels for tier {tier_collection}") + + # Get the discord.GuildChannel of the first available channel id + found_channel = guild.get_channel(free_tier_channel_ids[0]) + if not isinstance(found_channel, discord.TextChannel): + raise WrongChannelType( + f"For tier {tier_collection}, channel id {channel_id} is of type {type(found_channel)}, expected discord.TextChannel") + self.channel = found_channel + + # Assign the role for the tier collection to all players in the room so + # they can see the tier + tier_role_id = tier_data["tier_role_id"] + self.room_role = guild.get_role(tier_role_id) + if self.room_role is None: + raise RoleNotFound( + f"Could not find role for role id {tier_role_id} for tier {tier_collection}") + await self.assign_roles(guild=guild, role_skip=tier_data["role_ids_can_see_already"] + [tier_role_id]) 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 +513,135 @@ 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 adjusted_mmr(self): + minimum_mmr = common.CONFIG["MATCHMAKING_BOTTOM_MMR"] + maximum_mmr = common.CONFIG["MATCHMAKING_TOP_MMR"] + if minimum_mmr is None or maximum_mmr is None: + return self.mmr + if self.mmr < minimum_mmr: + return minimum_mmr + if self.mmr > maximum_mmr: + return maximum_mmr + return self.mmr + + @property + def is_matchmaking_mmr_adjusted(self): + return self.mmr != self.adjusted_mmr + + @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, + room_start_msg_link): super().__init__() - self.players = players - self.thread = thread + self.players: List[Player] = players + self.room_channel: discord.Thread | discord.TextChannel = thread self.mogi = mogi + self.room = room self.header_text = "" + self.message = None + self.room_start_msg_link = room_start_msg_link self.teams_text = "" self.found_winner = False - self.__setattr__("FFA", []) - self.__setattr__("2v2", []) - self.__setattr__("3v3", []) - self.__setattr__("4v4", []) + self.penalty_time = penalty_time + self.votes = {button["format_name"]: [] for button in mogi.buttons} + + async def create_message(self): + if self.mogi.format: + await self.room_channel.send( + content=f"The format **{self.mogi.format}** has been selected!", + view=self + ) + + players_per_team = 1 if self.mogi.format == "FFA" else int( + self.mogi.format.split('v')[0]) + + await self.make_teams(players_per_team, self.mogi.format) + else: + for button in self.mogi.buttons: + format_name = button["format_name"] + self.add_button(format_name, self.general_vote_callback) + + self.message = await self.room_channel.send( + view=self + ) + + async def make_teams(self, players_per_team: int, vote_str: str): + if common.SERVER is common.Server.MKW: + self.mogi.making_rooms_run_time = datetime.now(timezone.utc) + if vote_str == "6v6": + players_per_team = 1 + elif common.SERVER is common.Server.MK8DX: + self.mogi.making_rooms_run_time = self.mogi.start_time + \ + timedelta(minutes=5) + elif common.SERVER is common.Server.MKWorld: + self.mogi.making_rooms_run_time = self.mogi.start_time + \ + timedelta(minutes=5) + if common.SERVER is common.Server.MK8DX and vote_str == "6v6": + pop_to_end = [8, 7, 4, 3, 0] + for i in pop_to_end: + player = self.players.pop(i) + self.players.append(player) + else: + random.shuffle(self.players) - def __getitem__(self, key): - return getattr(self, key) + room = self.mogi.get_room_from_channel_id(self.room_channel.id) - async def make_teams(self, format): - random.shuffle(self.players) + msg = "ㅤ\n" - room = self.mogi.get_room_from_thread(self.thread.id) + if not self.mogi.format: + msg = "**Poll Ended!**\n\n" - 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" + for i, (format_name, votes) in enumerate(self.votes.items(), start=1): + msg += f"{i}) {format_name} - {len(votes)}\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" + msg += f"Winner: {vote_str}\n\n" + + self.header_text = "" + tier_text = "Tier " if common.SERVER is common.Server.MK8DX or common.SERVER is common.Server.MKWorld else "T" + self.header_text += f"**Room {room.room_num} MMR: {room.avg_mmr} - {tier_text}{room.tier}** " + msg += self.header_text + "\n" teams = [] - teams_per_room = int(12 / format[0]) + teams_per_room = self.mogi.players_per_room // players_per_team for j in range(teams_per_room): - team = Team(self.players[j*format[0]:(j+1)*format[0]]) + players = sorted(self.players[j * players_per_team:(j + 1) * players_per_team], + key=lambda player: player.mmr, reverse=True) + team = Team(players) teams.append(team) teams.sort(key=lambda team: team.avg_mmr, reverse=True) @@ -196,174 +649,216 @@ async def make_teams(self, format): scoreboard_text = [] for j in range(teams_per_room): - team_text = f"`{j+1}.` " - team_names = ", ".join([p.lounge_name for p in teams[j].players]) - scoreboard_text.append(team_names) + team_text = f"`{j + 1}.` " + team_names = ", ".join([discord.utils.escape_markdown( + p.lounge_name) for p in teams[j].players]) + raw_team_names = ", ".join( + [p.lounge_name for p in teams[j].players]) + scoreboard_text.append(raw_team_names) team_text += team_names team_text += f" ({int(teams[j].avg_mmr)} MMR)\n" msg += team_text self.teams_text += team_text - msg += f"\nTable: `/scoreboard`\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!" + if common.SERVER is common.Server.MK8DX: + msg += f"\nTable: `/scoreboard`\n" + msg += f"RandomBot Scoreboard: `/scoreboard {teams_per_room} {', '.join(scoreboard_text)}`\n" + elif common.SERVER is common.Server.MKW and vote_str == "6v6": + captain_1 = teams[0].players[0] + captain_2 = teams[1].players[0] + msg += f"\nCaptain #1: **{captain_1.lounge_name}**\n" + msg += f"Captain #2: **{captain_2.lounge_name}**\n" + msg += f"""Draft instructions: +1. **{captain_1.lounge_name}** picks 1 player +2. **{captain_2.lounge_name}** picks 2 players +3. **{captain_1.lounge_name}** picks 2 players +4. **{captain_2.lounge_name}** picks 2 players +5. **{captain_1.lounge_name}** picks 2 players +6. **{captain_2.lounge_name}** picks 1 players\n""" + elif common.SERVER is common.Server.MKWorld: + msg += f"\nTable: `/scoreboard`\n" + msg += f"RandomBot Scoreboard: `/scoreboard {teams_per_room} {', '.join(scoreboard_text)}`\n" + + 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 == "": + if common.SERVER is common.Server.MK8DX: + msg += f"\nRoom open at :{room_open_time.minute:02}, penalty at :{penalty_time.minute:02}. Good luck!" + elif common.SERVER is common.Server.MKW: + msg += f"\nPenalty is {self.penalty_time} minutes after the room opens. Good luck!" + elif common.SERVER is common.Server.MKWorld: + msg += f"\nRoom open at :{room_open_time.minute:02}, penalty at :{penalty_time.minute:02}. Good luck!" + else: + if common.SERVER is common.Server.MK8DX: + msg += f"\n{potential_host_str}" + msg += f"\nRoom open at :{room_open_time.minute:02}, penalty at :{penalty_time.minute:02}. Good luck!" + elif common.SERVER is common.Server.MKW: + cur_time = datetime.now(timezone.utc) + mkw_room_open_time = cur_time + timedelta(minutes=1) + pen_time = mkw_room_open_time + \ + timedelta(minutes=self.penalty_time) + msg += f"\nRoom open at :{mkw_room_open_time.minute:02}, penalty at :{pen_time.minute:02}. Good luck!" + elif common.SERVER is common.Server.MKWorld: + msg += f"\n{potential_host_str}" + msg += f"\nRoom open at :{room_open_time.minute:02}, penalty at :{penalty_time.minute:02}. Good luck!" room.teams = teams - self.found_winner = True - await self.thread.send(msg) + try: + await self.room_channel.send(msg) + if common.CONFIG["USE_THREADS"]: + new_thread_name = self.room_channel.name + \ + f" - {tier_text}{room.tier}" + await self.room_channel.edit(name=new_thread_name) + except discord.HTTPException as e: + print( + f"HTTP error while ending voting for room {self.room.room_num} in function end_voting, skipping to next room.", + flush=True) 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"]) - + self.found_winner = True + most_votes = len(max(self.votes.values(), key=len)) winners = [] - - if len(self["FFA"]) == max: - winners.append((1, "FFA")) - if len(self["2v2"]) == max: - winners.append((2, "2v2")) - if len(self["3v3"]) == max: - winners.append((3, "3v3")) - if len(self["4v4"]) == max: - winners.append((4, "4v4")) + for format_name, votes in self.votes.items(): + if len(votes) == most_votes: + players_per_team = 1 if format_name == "FFA" else int( + format_name.split('v')[0]) + winners.append((players_per_team, format_name)) winner = random.choice(winners) for curr_button in self.children: curr_button.disabled = True - 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) + if self.message: + try: + await self.message.edit(view=self) + except discord.errors.NotFound: + print( + f"Failed to edit message for interaction: {self.message.id}. Message is no longer valid.", flush=True) - @discord.ui.button(label="2v2: 0", custom_id="2v2") - async def two_button_callback(self, interaction, button): - 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")) - 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) + await self.make_teams(*winner) - @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) + 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="4v4: 0", custom_id="4v4") - async def four_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["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")) + if interaction.user.id not in [p.member.id for p in self.players]: + await interaction.response.send_message("You are not a player in this event.", ephemeral=True) + return + vote = interaction.data['custom_id'] + players_per_team = 1 if vote == "FFA" else int( + vote.split('v')[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]) >= self.mogi.players_per_room / 2: + 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["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) + try: + await interaction.response.edit_message(view=self) + except discord.errors.NotFound: + print( + f"Failed to edit message for interaction: {interaction.id}. Interaction is no longer valid.", flush=True) class JoinView(View): - def __init__(self, room, get_mmr): + def __init__(self, + room: Room, + get_rating_from_discord_id, + 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.get_rating_from_discord_id = get_rating_from_discord_id + 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): - await interaction.response.defer() - muted_role_id = 600495108999086090 - if interaction.user.get_role(muted_role_id): - await interaction.followup.send( - "Players with the muted role cannot use the sub button.", ephemeral=True) + async def button_callback(self, interaction: discord.Interaction, button): + if self.is_restricted is not None and self.is_restricted( + interaction.user): + await interaction.response.send_message( + "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.response.send_message( + "You are already in this room.", ephemeral=True) return try: - user_mmr = await self.get_mmr(interaction.user.id) - except: - await interaction.followup.send( + user_mmr = self.get_rating_from_discord_id(interaction.user.id) + if isinstance(user_mmr, int): + user_mmr = Player(None, "", user_mmr).adjusted_mmr + except BaseException: + await interaction.response.send_message( "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: + + 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) + await interaction.response.edit_message(view=self) + if not common.CONFIG["USE_THREADS"]: + try: + await self.room.assign_member_room_role(interaction.user) + except RoleFailure as e: + self.room.channel.send(str(e)) mention = interaction.user.mention - await self.room.thread.send(f"{mention} has joined the room.") + await self.room.channel.send(f"{mention} has joined the room.") else: - await interaction.followup.send( + await interaction.response.send_message( "You do not meet room requirements", ephemeral=True) -def get_tier(mmr: int): +def get_tier_mkw(mmr: int, players: List[Player]): + if mmr > 10499 and player_threshold_check(10500, players): + return '8' + if mmr > 8999 and player_threshold_check(9000, players): + return '7' + if mmr > 7999: + return '6' + if mmr > 6499: + return '5' + if mmr > 4999: + return '4' + if mmr > 3499: + return '3' + if mmr > 1999: + return '2' + if mmr > 499: + return '1' + else: + return '0' + + +def player_threshold_check(mmr: int, players: List[Player]): + for player in players: + if player.mmr < mmr: + return False + return True + + +def get_tier_mk8dx(mmr: int): if mmr > 14000: return 'X' if mmr > 13000: @@ -394,3 +889,7 @@ def get_tier(mmr: int): return 'FG' else: return 'G' + + +def get_tier_mkworld(mmr: int): + return 'Q' diff --git a/redeploy.sh b/redeploy.sh index 9db4c40..42b05d0 100644 --- a/redeploy.sh +++ b/redeploy.sh @@ -1,5 +1,7 @@ #!/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 stop mkwii-mogi-queuebot +docker rm mkwii-mogi-queuebot +docker container prune +docker build --tag mkwii-mogi-queuebot . +docker image prune +docker run -d --name mkwii-mogi-queuebot -v "$(pwd)/settings_data:/app/settings_data" --restart unless-stopped mkwii-mogi-queuebot diff --git a/redeploy_CT.sh b/redeploy_CT.sh new file mode 100644 index 0000000..12a1d25 --- /dev/null +++ b/redeploy_CT.sh @@ -0,0 +1,7 @@ +#!/bin/bash +docker stop ct-mkwii-mogi-queuebot +docker rm ct-mkwii-mogi-queuebot +docker container prune +docker build --tag ct-mkwii-mogi-queuebot . +docker image prune +docker run -d --name ct-mkwii-mogi-queuebot -v $(pwd)/settings_data:/app/settings_data --restart unless-stopped ct-mkwii-mogi-queuebot diff --git a/redeploy_MK8DX.sh b/redeploy_MK8DX.sh new file mode 100644 index 0000000..4450a13 --- /dev/null +++ b/redeploy_MK8DX.sh @@ -0,0 +1,7 @@ +#!/bin/bash +docker stop mogi-queuebot +docker rm mogi-queuebot +docker container prune +docker build --tag mogi-queuebot . +docker image prune +docker run -d --name mogi-queuebot -v $(pwd)/settings_data:/app/settings_data --restart unless-stopped mogi-queuebot \ No newline at end of file diff --git a/redeploy_MKWorld.sh b/redeploy_MKWorld.sh new file mode 100644 index 0000000..05be999 --- /dev/null +++ b/redeploy_MKWorld.sh @@ -0,0 +1,7 @@ +#!/bin/bash +docker stop mkworld-mogi-queuebot +docker rm mkworld-mogi-queuebot +docker container prune +docker build --tag mkworld-mogi-queuebot . +docker image prune +docker run -d --name mkworld-mogi-queuebot -v $(pwd)/settings_data:/app/settings_data --restart unless-stopped mkworld-mogi-queuebot \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5724417..9cb79ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,8 @@ aiohttp==3.8.4 discord.py==2.3.2 python-dateutil==2.8.2 +dill~=0.3.8 +pytz==2024.1 +autopep8==1.6.0 +pycodestyle==2.8.0 + 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/timezones.json b/timezones.json index 3ae77d6..5a94039 100644 --- a/timezones.json +++ b/timezones.json @@ -1,34 +1,130 @@ { - "ACDT": 10.5, - "ACST": 9.5, - "ADT": -3, - "AEDT": 11, - "AEST": 10, - "AKDT": -8, - "AKST": -9, - "AST": -4, - "AWDT": 9, - "AWST": 8, - "BST": 1, - "CDT": -5, - "CEST": 2, - "CET": 1, - "CLST": -3, - "CLT": -4, - "CST": -6, - "EDT": -4, - "EST": -5, - "GMT": 0, - "HDT": -9, - "HST": -8, - "IST": 5.5, - "JST": 9, - "KST": 9, - "MDT": -6, - "MST": -7, - "NDT": -2.5, - "NST": -3.5, - "PDT": -7, - "PST": -8, - "UTC": 0 -} \ No newline at end of file + "ACDT": { + "offset": 10.5, + "zone_name": "Australia/Adelaide" + }, + "ACST": { + "offset": 9.5, + "zone_name": "Australia/Adelaide" + }, + "ADT": { + "offset": -3, + "zone_name": "America/Halifax" + }, + "AEDT": { + "offset": 11, + "zone_name": "Australia/Sydney" + }, + "AEST": { + "offset": 10, + "zone_name": "Australia/Sydney" + }, + "AKDT": { + "offset": -8, + "zone_name": "America/Anchorage" + }, + "AKST": { + "offset": -9, + "zone_name": "America/Anchorage" + }, + "AST": { + "offset": -4, + "zone_name": "America/Anchorage" + }, + "AWDT": { + "offset": 9, + "zone_name": "Australia/Perth" + }, + "AWST": { + "offset": 8, + "zone_name": "Australia/Perth" + }, + "BST": { + "offset": 1, + "zone_name": "Europe/London" + }, + "CDT": { + "offset": -5, + "zone_name": "America/Chicago" + }, + "CEST": { + "offset": 2, + "zone_name": "Europe/Berlin" + }, + "CET": { + "offset": 1, + "zone_name": "Europe/Berlin" + }, + "CLST": { + "offset": -3, + "zone_name": "Chile/Continental" + }, + "CLT": { + "offset": -4, + "zone_name": "Chile/Continental" + }, + "CST": { + "offset": -6, + "zone_name": "America/Chicago" + }, + "EDT": { + "offset": -4, + "zone_name": "America/New_York" + }, + "EST": { + "offset": -5, + "zone_name": "America/New_York" + }, + "GMT": { + "offset": 0, + "zone_name": "Etc/GMT" + }, + "HDT": { + "offset": -9, + "zone_name": "Pacific/Honolulu" + }, + "HST": { + "offset": -8, + "zone_name": "Pacific/Honolulu" + }, + "IST": { + "offset": 5.5, + "zone_name": "Asia/Kolkata" + }, + "JST": { + "offset": 9, + "zone_name": "Asia/Tokyo" + }, + "KST": { + "offset": 9, + "zone_name": "Asia/Seoul" + }, + "MDT": { + "offset": -6, + "zone_name": "America/Denver" + }, + "MST": { + "offset": -7, + "zone_name": "America/Denver" + }, + "NDT": { + "offset": -2.5, + "zone_name": "Canada/Newfoundland" + }, + "NST": { + "offset": -3.5, + "zone_name": "Canada/Newfoundland" + }, + "PDT": { + "offset": -7, + "zone_name": "America/Los_Angeles" + }, + "PST": { + "offset": -8, + "zone_name": "America/Los_Angeles" + }, + "UTC": { + "offset": 0, + "zone_name": "Etc/UTC" + } +} diff --git a/unit_testing.py b/unit_testing.py new file mode 100644 index 0000000..9e84976 --- /dev/null +++ b/unit_testing.py @@ -0,0 +1,395 @@ +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) + elif common.SERVER is common.Server.MKWorld: + cls.threshold_function = lambda players: SquadQueue.mkworld_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 + # -64, -49, -36, ... 0, 1, 4, ... 64 + players = [Player(None, f"{i}", abs( + i - 8) * (i - 8), True) for i in range(17)] + # Set 36 rating to not confirmed so only one possibility occurs + players.pop(15) + # Only one possibility that matches threshold + correct_result = players[2:14].copy() + 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 + # -512, -343, ... 0, 1, 8, ... 512 + players = [Player(None, f"{i}", (i - 8)**3, True) for i in range(17)] + 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] + # Same as previous test, but since p15 is not confirmed, no result is + # found + correct_result = [] + 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)