from __future__ import annotations
from copy import copy
import discord
import logging
import asyncio
import aiohttp
import contextlib
import aiofiles
import aiofiles.os
import filecmp
import csv
import shutil
from discord.ext import tasks
from random import choice, shuffle
from itertools import islice
from datetime import datetime, timedelta, timezone
from babel.dates import format_date, format_time
from typing import Mapping, Optional, Tuple, List, Union
from redbot import __version__ as red_version
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.i18n import Translator, get_babel_locale, set_contextual_locales_from_guild
from redbot.core.data_manager import cog_data_path
from redbot.core.utils.chat_formatting import humanize_timedelta, pagify
log = logging.getLogger("red.laggron.tournaments")
_ = Translator("Tournaments", __file__)
MAX_ERRORS = 5
TIME_UNTIL_CHANNEL_DELETION = 300
TIME_UNTIL_TIMEOUT_DQ = 300
[docs]class Participant(discord.Member):
"""
Defines a participant in the tournament.
This inherits from `discord.Member` and adds the necessary additional methods.
If you're implementing this for a new provider, the following methods need to be implemented:
* `player_id` (may be a var or a property)
* `destroy`
Parameters
----------
member: discord.Member
The member participating to the tournament
tournament: Tournament
The current tournament
Attributes
----------
member: discord.Member
The member participating to the tournament
tournament: Tournament
The current tournament
elo: int
An integer representing the player's elo (seeding from braacket)
checked_in: bool
Defines if the member checked in
match: Optional[Match]
The player's current match. `None` if not in game.
spoke: bool
Defines if the member spoke once in their channel (used for AFK check)
"""
def __init__(self, member: discord.Member, tournament: Tournament):
# code from discord.Member._copy
self._roles = discord.utils.SnowflakeList(member._roles, is_sorted=True)
self.joined_at = member.joined_at
self.premium_since = member.premium_since
self._client_status = member._client_status.copy()
self.guild = member.guild
self.nick = member.nick
self.activities = member.activities
self._state = member._state
# Reference will not be copied unless necessary by PRESENCE_UPDATE
self._user = member._user
# now our own stuff
self.tournament = tournament
self._player_id = None
self.elo = None # ranking and seeding stuff
self.checked_in = False
self.match: Optional[Match] = None
self.spoke = False # True as soon as the participant sent a message in their channel
# used to detect inactivity after the launch of a set
def __repr__(self):
return (
"<Participant name={1.name!r} id={1.id} player_id={0.player_id} tournament_name={0.tournament.name} "
"tournament_id={0.tournament.id} spoke={0.spoke}>"
).format(self, self._user)
@classmethod
def from_saved_data(cls, tournament: Tournament, data: dict):
member = tournament.guild.get_member(data["discord_id"])
if member is None:
log.warning(f"[Guild {tournament.guild.id}] Lost participant {data['discord_id']}")
return None
participant = cls(member, tournament)
participant._player_id = data["player_id"]
participant.spoke = data["spoke"]
participant.checked_in = data["checked_in"]
return participant
def to_dict(self) -> dict:
return {
"discord_id": self.id,
"player_id": self.player_id,
"spoke": self.spoke,
"checked_in": self.checked_in,
}
[docs] def reset(self):
"""
Resets the `match` attribute to `None` and `spoke` to `False` (match ended).
"""
self.match = None
self.spoke = False
[docs] async def check(self, send_dm: bool = True):
"""
Checks the member in.
In addition to changing the `checked_in` attribute, it also DMs the member
and saves the config.
"""
self.checked_in = True
log.debug(f"[Guild {self.guild.id}] Player {self} registered.")
await self.tournament.save()
if not send_dm:
return
try:
await self.send(
_("You successfully checked in for the tournament **{name}**!").format(
name=self.tournament.name
)
)
except discord.Forbidden:
pass
@property
def player_id(self):
"""
Returns an identifier for the player, specific to the bracket.
This should be overwritten.
"""
raise NotImplementedError
[docs] async def destroy(self):
"""
Removes the participant from the tournament.
Represents an API call and should be overwritten.
"""
raise NotImplementedError
[docs]class Match:
"""
Defines a match in the tournament, with two players facing each other.
This should only be created when convenient, aka when a match needs to be started. Matches
with no players yet or finished are not builded.
If you're implementing this for a new provider, the following methods need to be implemented:
* `set_scores`
* `mark_as_underway`
* `unmark_as_underway` (unused for now)
Parameters
----------
tournament: Tournament
The match's tournament.
round: str
The round of this match in the bracket (e.g. 1 for first round of winner bracket, -1 for
first round of loser bracket).
set: str
The number of the match. Challonge calls this the suggested play order (goes from 1 to N,
the number of matches in the bracket).
id: int
A unique identifier for the API
underway: bool
If the match is underway (provided by API)
player1: Participant
The first player of this match.
player2: Participant
The second player of this match.
Attributes
----------
tournament: Tournament
The match's tournament.
round: str
The round of this match in the bracket (e.g. 1 for first round of winner bracket, -1 for
first round of loser bracket).
set: str
The number of the match. Challonge calls this the suggested play order (goes from 1 to N,
the number of matches in the bracket).
id: int
A unique identifier for the API
player1: Participant
The first player of this match.
player2: Participant
The second player of this match.
channel: Optional[discord.TextChannel]
The channel for this match. May be `None` if the match hasn't started yet, or if it
couldn't be created and is therefore in DM.
start_time: Optional[datetime]
Start time of this match. `None` if it hasn't started yet.
end_time: Optional[datetime]
End time of this match, or time of the latest message. `None` if it hasn't started or
ended yet. This is updated as soon as a message is sent in the channel. Used for knowing
when to delete the channel (5 min after last message).
status: str
Defines the current state of the match.
* ``"pending"``: Waiting to be launched (no channel or stream pending)
* ``"ongoing"``: Match started
* ``"finished"``: Score set, awaiting channel deletion
warned: Optional[Union[datetime, bool]]
Defines if there was a warn for duration. `None` if no warn was sent, `datetime.datetime`
if there was one first warn sent (correspond to the time when it was send, we rely on that
to know when to send the second warn), and finally `True` when the second warn is sent
(to the T.O.s).
streamer: Optional[Streamer]
The streamer assigned to this match, if any.
on_hold: bool
`True` if the match is not started but on hold, awaiting something (most likely inside a
stream queue, waiting for its turn)
is_top8: bool
`True` if the match is in the top 8 of the tournament
is_bo5: bool
`True` if the match is in BO5 format instead of BO3
round_name: str
Name of the round (ex: Winner semi-finals, Loser round -2)
checked_dq: bool
If we performed AFK checks. Setting this to `True` is possible and will disable further
AFK checks for this match.
"""
def __init__(
self,
tournament: Tournament,
round: int,
set: str,
id: int,
underway: bool,
player1: Participant,
player2: Participant,
):
self.guild: discord.Guild = tournament.guild
self.tournament = tournament
self.round = round
self.set = set
self.id = id
self.underway = underway
self.player1 = player1
self.player2 = player2
self.channel: Optional[discord.TextChannel] = None
self.start_time: Optional[datetime] = None
self.end_time: Optional[datetime] = None
self.status = "pending" # can be "pending" "ongoing" "finished"
self.warned: Optional[Union[datetime, bool]] = None
# time of the first warn for duration, if any. if a second warn was sent, set to True
self.streamer: Optional[Streamer] = None
self.on_hold = False # True if this is match is awaiting for a stream
# one or more players can be None
# if this is the case, the bot will most likely close the match right after this
with contextlib.suppress(AttributeError):
player1.match = self
player2.match = self
self.is_top8 = (
round >= tournament.top_8["winner"]["top8"]
or round <= tournament.top_8["loser"]["top8"]
)
self.is_bo5 = (
round >= tournament.top_8["winner"]["bo5"] or round <= tournament.top_8["loser"]["bo5"]
)
self.round_name = self._get_name()
self.checked_dq = True if self.is_top8 else False
def __repr__(self):
return (
"<Match status={0.status} round={0.round} set={0.set} id={0.id} underway={0.underway} "
"channel={0.channel} start={0.start_time} tournament={0.tournament.name} "
"guild_id={0.guild.id} player1={0.player1.name} player2={0.player2.name}>"
).format(self)
def __del__(self):
if self.streamer is not None:
self.streamer.current_match = None
try:
self.streamer.matches.remove(self)
except ValueError:
pass
if self.tournament.cancelling is False and self.channel:
channel = self.guild.get_channel(self.channel.id)
if channel is not None:
log.warning(
f"[Guild {self.guild.id}] Set {self.set} removed from memory while "
f"the text channel with ID {channel.id} still exists."
)
@property
def duration(self) -> Optional[timedelta]:
"""
Returns the duration of this match, or `None` if it hasn't started.
"""
if self.start_time:
return datetime.now(self.tournament.tz) - self.start_time
@classmethod
def from_saved_data(cls, tournament: Tournament, player1, player2, data: dict) -> Match:
match = cls(
tournament, data["round"], data["set"], data["id"], data["underway"], player1, player2
)
match.channel = tournament.guild.get_channel(data["channel"])
warned = data["warned"]
if isinstance(warned, bool) or warned is None:
match.warned = warned
else:
match.warned = datetime.fromtimestamp(warned, tz=tournament.tz)
match.on_hold = bool(data["on_hold"])
match.status = data["status"]
match.checked_dq = data["checked_dq"]
match.start_time = (
datetime.fromtimestamp(data["start_time"], tz=tournament.tz)
if data["start_time"]
else None
)
match.end_time = (
datetime.fromtimestamp(data["end_time"], tz=tournament.tz)
if data["end_time"]
else None
)
return match
def to_dict(self) -> dict:
return {
"round": self.round,
"set": self.set,
"id": self.id,
"underway": self.underway,
"player1": self.player1.player_id,
"player2": self.player2.player_id,
"channel": self.channel.id if self.channel else None,
"start_time": self.start_time.timestamp() if self.start_time else None,
"end_time": self.end_time.timestamp() if self.end_time else None,
"status": self.status,
"checked_dq": self.checked_dq,
"warned": self.warned.timestamp()
if isinstance(self.warned, datetime)
else self.warned,
"on_hold": self.on_hold,
}
def _get_name(self) -> str:
if self.round > 0:
max_round = self.tournament.top_8["winner"]["top8"] + 2
return {
max_round: _("Grand Final"),
max_round - 1: _("Winners Final"),
max_round - 2: _("Winners Semi-Final"),
max_round - 3: _("Winners Quarter-Final"),
}.get(self.round, _("Winners round {round}").format(round=self.round))
elif self.round < 0:
max_round = self.tournament.top_8["loser"]["top8"] - 3
return {
max_round: _("Losers Final"),
max_round + 1: _("Losers Semi-Final"),
max_round + 2: _("Losers Quarter-Final"),
}.get(self.round, _("Losers round {round}").format(round=self.round))
async def _dm_players(self, message: str):
players = (self.player1, self.player2)
for player in players:
try:
await player.send(message)
except discord.HTTPException as e:
log.warning(f"Can't send a DM to {str(player)} for their set.", exc_info=e)
[docs] async def send_message(self, reset: bool = False) -> bool:
"""
Send a message in the created channel.
Parameters
----------
reset: bool
``True`` if the match is started because of a reset.
Returns
-------
bool
``False`` if the message couldn't be sent, and was sent in DM instead.
"""
if reset is True:
message = _(
":warning: **The bracket was modified!** This results in this match having to be "
"replayed. Please check your new position on the bracket.\n\n"
)
else:
message = ""
top8 = _("**(top 8)** :fire:") if self.is_top8 else ""
message += _(
":arrow_forward: **{0.set}** : {0.player1.mention} vs {0.player2.mention} {top8}\n"
).format(self, top8=top8)
if self.tournament.ruleset_channel:
message += _(
":white_small_square: The rules must follow the ones given in {channel}\n"
).format(channel=self.tournament.ruleset_channel.mention)
if self.tournament.stages:
message += _(
":white_small_square: The list of legal stages "
"is available with `{prefix}stages` command.\n"
).format(prefix=self.tournament.bot_prefix)
if self.tournament.counterpicks:
message += _(
":white_small_square: The list of counter stages "
"is available with `{prefix}counters` command.\n"
).format(prefix=self.tournament.bot_prefix)
score_channel = (
_("in {channel}").format(channel=self.tournament.scores_channel.mention)
if self.tournament.scores_channel
else ""
)
message += _(
":white_small_square: In case of lag making the game unplayable, use the "
"`{prefix}lag` command to call the T.O. and solve the problem.\n"
":white_small_square: **As soon as the set is done**, the winner sets the "
"score {score_channel} with the `{prefix}win` command.\n"
":arrow_forward: You will play this set as a {type}.\n"
).format(
prefix=self.tournament.bot_prefix,
score_channel=score_channel,
type=_("**BO5** *(best of 5)*") if self.is_bo5 else _("**BO3** *(best of 3)*"),
)
if self.tournament.baninfo:
chosen_player = choice([self.player1, self.player2])
message += _(
":game_die: **{player}** was picked to begin the bans *({baninfo})*.\n"
).format(player=chosen_player.mention, baninfo=self.tournament.baninfo)
if self.streamer is not None and self.on_hold is True:
message += _(
"**\nYou will be on stream on {streamer}!**\n"
":warning: **Do not play your set for now and wait for your turn.** "
"I will send a message once it is your turn with instructions."
).format(streamer=self.streamer.link)
# else, we're about to send another message with instructions
async def send_in_dm():
nonlocal message
message += _(
"\n\n**You channel can't be created because of a problem. "
"Do your set in DM and come back to set the result.**"
)
await self._dm_players(message)
if self.channel is None:
await send_in_dm()
result = False
else:
try:
await self.channel.send(message)
except discord.HTTPException as e:
log.error(
f"[Guild {self.guild.id}] Can't create a channel for the set {self.set}",
exc_info=e,
)
await send_in_dm()
result = False
else:
result = True
self.tournament.matches_to_announce.append(
_(
":arrow_forward: **{name}** ({bo_type}): {player1} vs {player2}"
"{on_stream} {top8} {channel}."
).format(
name=self.round_name,
bo_type=_("BO5") if self.is_bo5 else _("BO3"),
player1=self.player1.mention,
player2=self.player2.mention,
on_stream=_(" **on stream!**") if self.streamer else "",
top8=top8,
channel=_("in {channel}").format(channel=self.channel.mention)
if result is True
else _("in DM"),
)
)
return result
[docs] async def start_stream(self):
"""
Send a pending set, awaiting for its turn, on stream. Only call this if there's a streamer.
"""
destination = self.channel.send if self.channel else self._dm_players
if self.streamer.room_id:
access = _("\n\nHere are the access codes:\nID: {id}\nPasscode: {passcode}").format(
id=self.streamer.room_id, passcode=self.streamer.room_code
)
else:
access = ""
if self.status != "ongoing":
await self._start()
self.on_hold = False
self.checked_dq = True
await destination(
_("You can go on stream on {channel} !{access}").format(
channel=self.streamer.link, access=access
)
)
if self.tournament.stream_channel:
await self.tournament.stream_channel.send(
_(
":arrow_forward: Sending set {set} ({name}) on stream "
"with **{streamer}**: {player1} vs {player2}"
).format(
set=self.set,
name=self._get_name(),
streamer=self.streamer.channel,
player1=self.player1.mention,
player2=self.player2.mention,
),
allowed_mentions=discord.AllowedMentions(users=False),
)
[docs] async def stream_queue_add(self):
"""
Modify the status of an ongoing match to tell that it is now on stream.
This is called when a streamer adds an ongoing match to its queue, then the following
things are done:
* AFK checks are cancelled
* **If the match is the first one in the stream queue:** Nothing is cancelled, we just
ping the players with the stream informations.
* **If the match is not the first one in the stream queue:** We mark the match as not
underway, change the status to pending, and tell the players.
"""
destination = self.channel.send if self.channel else self._dm_players
self.checked_dq = True
if self.on_hold is True:
self.status = "pending"
self.start_time = None
self.underway = False
await destination(
_(
"{player1} {player2}\n"
":warning: Your match was just added to {channel}'s stream queue ({link})\n"
"**You must stop playing now and wait for your turn.** "
"Sorry for this sudden change, please contact a T.O. if you have a question."
).format(
player1=self.player1.mention,
player2=self.player2.mention,
channel=self.streamer.channel,
link=self.streamer.link,
)
)
try:
await asyncio.wait_for(self.unmark_as_underway(), timeout=60)
except Exception as e:
log.warning(
f"[Guild {self.guild.id}] Can't unmark set {self.set} as underway.", exc_info=e
)
await self.tournament.to_channel.send(
_(
"There was an issue unmarking set {set} as underway. The bracket may not "
"display correct informations, but this isn't critical at all.\n"
"Players may have issues setting their score, "
"you can set that manually on the bracket."
).format(set=self.channel.mention if self.channel else f"#{self.set}")
)
await destination(
_(
"There was an issue unmarking your set as underway. The bracket may not "
"display correct informations, but this isn't critical.\n"
"If you encounter an issue setting your score, contact a T.O."
)
)
else:
if self.streamer.room_id:
access = _(
"\n\nHere are the access codes:\nID: {id}\nPasscode: {passcode}"
).format(id=self.streamer.room_id, passcode=self.streamer.room_code)
else:
access = ""
await destination(
_(
"{player1} {player2}\n"
":warning: Your match was just added to {channel}'s stream ({link}).\n"
"**You must now play on stream!**{access}"
).format(
player1=self.player1.mention,
player2=self.player2.mention,
channel=self.streamer.channel,
link=self.streamer.link,
access=access,
)
)
[docs] async def cancel_stream(self):
"""
Call if the stream is cancelled (streamer left of match removed from queue).
A message will be sent, telling players to start playing, and AFK checks will be
re-enabled.
"""
destination = self.channel.send if self.channel else self._dm_players
self.streamer = None
self.on_hold = False
await self._start()
await destination(
_(
"{player1} {player2} The stream was cancelled. You can start/continue "
"your match normally.\n:warning: AFK checks are re-enabled."
).format(player1=self.player1.mention, player2=self.player2.mention)
)
[docs] async def create_channel(
self, category: discord.CategoryChannel, *allowed_roles: list
) -> discord.TextChannel:
"""
Creates a channel for the match and returns its object.
Returns
-------
discord.TextChannel
The created text channel
"""
overwrites = {
self.guild.me: discord.PermissionOverwrite(
read_messages=True, send_messages=True, manage_channels=True
),
self.guild.default_role: discord.PermissionOverwrite(read_messages=False),
self.player1: discord.PermissionOverwrite(read_messages=True, send_messages=True),
self.player2: discord.PermissionOverwrite(read_messages=True, send_messages=True),
}
for role in allowed_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
return await self.guild.create_text_channel(
self.set, category=category, overwrites=overwrites, reason=_("Set launch")
)
async def _start(self):
target = self.channel.send if self.channel else self._dm_players
self.status = "ongoing"
self.underway = True
self.checked_dq = False
self.start_time = datetime.now(self.tournament.tz)
try:
await asyncio.wait_for(self.mark_as_underway(), timeout=60)
except Exception as e:
log.warning(
f"[Guild {self.guild.id}] Can't mark set {self.set} as underway.", exc_info=e
)
await self.tournament.to_channel.send(
_(
"There was an issue marking set {set} as underway. The bracket may not "
"display correct informations, but this isn't critical at all.\n"
"Players may have issues setting their score, "
"you can set that manually on the bracket."
).format(set=self.channel.mention if self.channel else f"#{self.set}")
)
await target(
_(
"There was an issue marking your set as underway. The bracket may not "
"display correct informations, but you can play as usual for now.\n"
"If you encounter an issue setting your score, contact a T.O."
)
)
[docs] async def launch(
self,
*,
category: Optional[discord.CategoryChannel] = None,
restart: bool = False,
allowed_roles: List[discord.Role] = [],
):
"""
Launches the set.
This does the following:
* Try to create a text channel with permissions for the two players and the given roles
* Send a DM to both members
* Mark the set as ongoing
Parameters
----------
category: Optional[discord.CategoryChannel]
The category where to put the channel. If this is not provided, one will be found.
If you're launching multiple sets at once with asyncio.gather, use this to prevent
seeing one category per channel
restart: bool
If the match is restarted.
allowed_roles: List[discord.Role]
A list of roles with read_messages permission in the text channel.
"""
if category is None:
category = await self.tournament._get_available_category(
"winner" if self.round > 0 else "loser"
)
allowed_roles = list(allowed_roles)
allowed_roles.extend(self.tournament.allowed_roles)
try:
channel = await self.create_channel(category, *allowed_roles)
except discord.HTTPException as e:
log.error(
f"[Guild {self.guild.id}] Couldn't create a channel for the set {self.set}.",
exc_info=e,
)
else:
self.channel = channel
await self.send_message(reset=restart)
if self.on_hold is False:
await self._start()
if self.streamer is not None:
await self.start_stream()
[docs] async def relaunch(self):
"""
This is called in case of a match reset (usually from remote).
We inform the players they need to play their set again, eventually re-use their old
channel if it still exists.
"""
if self.channel is not None:
await self.mark_as_underway()
self.status = "ongoing"
await self.channel.send(
_(
"{player1} {player2}\n"
":warning: The score of this set was reset on the bracket. "
"Therefore, **the set must be replayed**. Ask the T.O. if you have "
"questions or believe this is a mistake."
).format(player1=self.player1.mention, player2=self.player2.mention)
)
self.underway = True
self.start_time = datetime.now(self.tournament.tz)
else:
await self.launch(restart=True)
[docs] async def check_inactive(self):
"""
Checks for inactive players (reads `Participant.spoke` only), and DQ them if required.
.. warning:: This doesn't check durations and assumes it's been done already.
Will set `checked_dq` to `True`.
"""
self.checked_dq = True
players = (self.player1, self.player2)
if all((x.spoke is False for x in players)):
log.debug(
f"[Guild {self.guild.id}] Both players inactive, DQing "
f"both and cancelling set #{self.set}."
)
await self.player1.destroy()
await self.player2.destroy()
try:
await self.channel.delete()
except discord.HTTPException as e:
log.warn(
f"[Guild {self.guild.id}] Can't delete set channel #{self.set}.", exc_info=e
)
await self.tournament.to_channel.send(
_(
":information_source: **Automatic DQ** of {player1} and {player2} for "
"inactivity, the set #{set} is cancelled."
).format(player1=self.player1.mention, player2=self.player2.mention, set=self.set)
)
self.channel = None
self.cancel()
return
for player in players:
if player.spoke is False:
log.debug(
f"[Guild {self.guild.id}] DQing player {player} "
f"for inactivity (set #{self.set})"
)
await player.destroy()
await self.channel.send(
_(":timer: **Automatic DQ of {player} for inactivity.**").format(
player=player.mention
)
)
await self.tournament.to_channel.send(
_(
":information_source: **Automatic DQ** of {player} "
"for inactivity, set #{set}."
).format(player=player.mention, set=self.set)
)
try:
await player.send(
_(
"Sorry, you were disqualified from this tournament "
"because you weren't active in your channel. Contact the T.O. "
"if you believe this is a mistake."
)
)
except discord.HTTPException:
# blocked or DMs not allowed
pass
self.cancel()
break
[docs] async def warn_length(self):
"""
Warn players in their channels because of the duration of their match.
"""
target = self.channel.send or self._dm_players
message = _(
":warning: This match is taking a lot of time!\n"
"As soon as this is finished, set your score with `{prefix}win`{channel}."
).format(
prefix=self.tournament.bot_prefix,
channel=_(" in {channel}").format(channel=self.tournament.scores_channel.mention)
if self.tournament.scores_channel
else "",
)
time = self.tournament.time_until_warn["bo5" if self.is_bo5 else "bo3"][1]
if time:
message += _(
"\nT.O.s will be warned if this match is still ongoing in {time}."
).format(time=humanize_timedelta(timedelta=time))
try:
await target(message)
except discord.NotFound:
self.channel = None
self.warned = datetime.now(self.tournament.tz)
[docs] async def warn_to_length(self):
"""
Warn T.O.s because of the duration of this match. Also tell the players
"""
await self.tournament.to_channel.send(
_(
":warning: The set {set} is taking a lot of time (now open since {time} minutes)."
).format(
set=self.channel.mention
if self.channel
else _("#{set} (in DM)").format(set=self.set),
time=round(self.duration.total_seconds() // 60),
)
)
self.warned = True
target = self.channel.send or self._dm_players
await target(_("Your match is taking too much time, T.O.s were warned."))
[docs] async def end(self, player1_score: int, player2_score: int, upload: bool = True):
"""
Set the score and end the match.
The winner is determined by comparing the two scores (defaults to player 1 if equal).
Parameters
----------
player1_score: int
First player's score.
player2_score: int
Second player's score.
upload: bool
If the score should be uploaded to the bracket. Set `False` to only send the message
with a note "score set on bracket" added. Defaults to `True`.
"""
if upload is True:
await self.set_scores(player1_score, player2_score)
self.cancel()
winner = self.player1 if player1_score > player2_score else self.player2
score = (
f"{player1_score}-{player2_score}"
if player1_score > player2_score
else f"{player2_score}-{player1_score}"
)
if self.channel is not None:
await self.channel.send(
_(
":bell: __Score reported__ : **{winner}** wins{score} !\n"
"*In case of a problem, call a T.O. to fix the score.*\n"
"*Note : this channel will be deleted after 5 minutes of inactivity.*{remote}"
).format(
winner=winner.mention,
score=f" **{score}**" if player1_score + player2_score > 0 else "",
remote=_("\n\n:information_source: The score was directly set on the bracket.")
if upload is False
else "",
)
)
[docs] async def force_end(self):
"""
Called when a set is cancelled (remove bracket modifications or reset).
The channel is deleted, a DM is sent, and the instance will most likely be deleted soon
after.
"""
self.player1.reset()
self.player2.reset()
for player in (self.player1, self.player2):
try:
await player.send(
_(
":warning: **Your set is cancelled!** This is probably because of "
"manual bracket modifications.\nCheck the bracket, and contact a T.O. "
"if you believe this is a problem."
)
)
except discord.HTTPException:
pass
if self.channel is not None:
try:
await self.channel.delete(
reason=_(
"Remote returned a different match list, or the bracket was reset. I am "
"therefore clearing the outdated matches. Check the bracket for details."
)
)
except discord.HTTPException as e:
log.warn(
f"[Guild {self.guild.id}] Can't delete set channel #{self.set}.", exc_info=e
)
[docs] async def disqualify(self, player: Union[Participant, int]):
"""
Called when a player in the set is destroyed.
There is no API call, just messages sent to the players.
player: Union[Participant, int]
The disqualified player. Provide an `int` if the member left.
"""
self.cancel()
if isinstance(player, int):
winner = (
self.player1
if self.player1 is not None and self.player1.player_id != player
else self.player2
)
player = _("Player with ID {id} (lost on Discord) ").format(id=player)
else:
winner = self.player1 if self.player1.id != player.id else self.player2
player = _("Player {player}").format(player=player.mention)
if self.channel is not None:
await self.channel.send(
_(
"{player} disqualified from the tournament.\n"
"{winner.mention} is winning this set!"
).format(player=player, winner=winner)
)
else:
try:
await winner.send(
_(
"Your opponent was disqualified from the tournament.\n"
"You are winning this set!"
)
)
except discord.HTTPException:
pass
[docs] async def forfeit(self, player: Participant):
"""
Called when a player in the set forfeits this match.
This doesn't always mean that the player quits the tournament, as they may continue in the
loser bracket.
Sets a score of -1 0
Parameters
----------
player: Participant
The player that forfeits.
"""
if player.id == self.player1.id:
score = (-1, 0)
else:
score = (0, -1)
await self.set_scores(*score)
self.cancel()
winner = self.player1 if self.player1.id != player.id else self.player2
if self.channel is not None:
await self.channel.send(
_(
"Player {player.mention} forfeits this set.\n{winner.mention} is winning!"
).format(player=player, winner=winner)
)
else:
try:
await winner.send(_("Your opponent forfeited.\nYou are winning this set!"))
except discord.HTTPException:
pass
[docs] def cancel(self):
"""
Mark a match as finished (updated `status` and `end_time` + calls `Participant.reset`)
"""
with contextlib.suppress(AttributeError):
self.player1.reset()
self.player2.reset()
self.status = "finished"
self.end_time = datetime.now(self.tournament.tz)
[docs] async def set_scores(
self, player1_score: int, player2_score: int, winner: Optional[Participant] = None
):
"""
Set the score for the set.
Parameters
----------
player1_score: int
The score of the first player.
player2_score: int
The score of the second player.
winner: Optional[Participant]
The winner of the set. If not provided, the player with the highest score will be
selected.
"""
raise NotImplementedError
[docs] async def mark_as_underway(self):
"""
Marks the match as underway.
"""
raise NotImplementedError
[docs] async def unmark_as_underway(self):
"""
Unmarks the match as underway.
This shouldn't ever be needed, just here in case of.
"""
raise NotImplementedError
[docs]class Tournament:
"""
Represents a tournament in a guild.
This object is created as soon as the tournament is setup, and destroyed only once it ends.
The config is loaded inside and will not be updated unless reloaded.
This contains all of the methods useful for the tournament, a list of `Participant` and a list
of `Match`, and a `discord.ext.tasks.Loop` task updating the infos from the bracket.
This contains the base structure, but no interface with a bracket, this has to be implemented
later by inheriting from this class and overwriting the abstract methods, allowing multiple
providers to work with the same structure.
If you're implementing this for a new provider, the following methods need to be implemented:
* `_get_all_rounds`
* `_update_match_list`
* `_update_participants_list`
* `start`
* `stop`
* `add_participant`
* `add_participants`
* `destroy_player`
* `list_participants`
* `list_matches`
* `reset`
And set the following class vars with your other inherited objects for `Participant` and
`Match` :
* `match_object`
* `participant_object`
See ``challonge.py`` for an example.
Parameters
----------
bot: redbot.core.bot.Red
The bot object
guild: discord.Guild
The current guild for the tournament
config: redbot.core.Config
The cog's Config object
name: str
Name of the tournament
game: str
Name of the game
url: str
Link of the bracket
id: str
Internal ID for the tournament
limit: Optional[int]
An optional limit of participants
status: str
The status provided by the API
tournament_start: datetime.datetime
Expected start time for this tournament. Planned events are based on this.
bot_prefix: str
A prefix to use for displaying commands without context.
cog_version: str
Current version of Tournaments
data: dict
A dict with all the config required for the tournament (combines guild and game settings)
Attributes
----------
bot: redbot.core.bot.Red
The bot object
guild: discord.Guild
The current guild for the tournament
config: redbot.core.Config
The cog's Config object
name: str
Name of the tournament
game: str
Name of the game
url: str
Link of the bracket
id: str
Internal ID for the tournament
limit: Optional[int]
An optional limit of participants
status: str
The status provided by the API
tournament_start: datetime.datetime
Expected start time for this tournament. Planned events are based on this.
tz: datetime.tzinfo
The timezone of the tournament. You need to use this when creating datetime objects.
.. code-block:: python
from datetime import datetime
now = datetime.now(tz=tournament.tz)
bot_prefix: str
A prefix to use for displaying commands without context.
cog_version: str
Current version of Tournaments
participants: List[Participant]
List of participants in the tournament
matches: List[Match]
List of open matches in the tournament
streamers: List[Streamer]
List of streamers in the tournament
winner_categories: List[discord.CategoryChannel]
List of categories created for the winner bracket
loser_categories: List[discord.CategoryChannel]
List of categories created for the loser bracket
category: Optional[discord.CategoryChannel]
The category defined (our categories will be created below)
announcements_channel: Optional[discord.TextChannel]
The channel for announcements
checkin_channel: Optional[discord.TextChannel]
The channel for check-in
queue_channel: Optional[discord.TextChannel]
The channel for match queue
register_channel: Optional[discord.TextChannel]
The channel for registrations
scores_channel: Optional[discord.TextChannel]
The channel for score setting
stream_channel: Optional[discord.TextChannel]
The channel for announcing matches on stream
to_channel: discord.TextChanne]
The channel for tournament organizers. Send warnings there.
vip_register_channel: Optional[discord.TextChannel]
A channel where registrations are always open
participant_role: discord.Role
The role given to participants
streamer_role: Optional[discord.Role]
Role giving access to stream commands
to_role: Optional[discord.Role]
Role giving access to T.O. commands
credentials: dict
Credentials for connecting to the bracket
delay: int
Time in minutes until disqualifying a participant for AFK
time_until_warn: dict
Represents the different warn times for duration
autostop_register: bool
Should the bot close registrations when it's full?
ignored_events: list
A list of events to ignore (checkin/register start/stop)
register_start: Optional[datetime.datetime]
When we should open the registrations automatically
register_second_start: Optional[datetime.datetime]
When we should open the registrations a second time automatically
register_stop: Optional[datetime.datetime]
When we should close the registrations automatically
checkin_start: Optional[datetime.datetime]
When we should open the checkin automatically
checkin_stop: Optional[datetime.datetime]
When we should close the checkin automatically
ruleset_channel: Optional[discord.TextChannel]
Channel for the rules
game_role: Optional[discord.Role]
Role targeted at players for this game. Basically we use that role when opening the
registrations, for opening the channel and pinging.
baninfo: Optional[str]
Baninfo set (ex: 3-4-2)
ranking: dict
Data for braacket ranking
stages: List[str]
List of allowed stages
counterpicks: List[str]
List of allowed counterpicks
phase: str
Something very important! Used for knowing what is the current phase of the tournament.
It is also used by commands to know if it is allowed to run.
Can be the following values:
* ``"pending"``: Tournament just setup, nothing open yet
* ``"register"``: Registrations or check-in started and are not finished
* ``"awaiting"``: Registrations and check-in done, awaiting upload and start
* ``"ongoing"``: Tournament is started and ongoing
* ``"finished"``: Tournament done. Should be immediatly deleted unless there's an issue
register_phase: str
Defines the current status of registration.
Can be the following values:
* ``"manual"``: No start date setup, waiting for manual start
* ``"pending"``: Start date setup, awaiting automatic start
* ``"ongoing"``: Registrations active
* ``"onhold"``: Registrations started and ended once, but awaiting a second start
* ``"done"``: Registrations ended
checkin_phase: str
Defines the current status of check-in.
Can be the following values:
* ``"manual"``: No start date setup, waiting for manual start
* ``"pending"``: Start date setup, awaiting automatic start
* ``"ongoing"``: Check-in active
* ``"onhold"``: Check-in started and ended once, but awaiting a second start
* ``"done"``: Check-in ended
register_message: Optional[discord.Message]
The pinned message in the registrations channel
checkin_reminders: List[Tuple[int, bool]]
A list of reminders to send for the check-in. Contains tuples of two items: when to send
the reminder (minutes before check-in end date), and if the bot should DM members. This is
calculated on check-in start.
lock: asyncio.Lock
A lock acquired when the tournament is being refreshed by the loop task, to prevent
commands like win or dq from being run at the same time.
*New since beta 13:* The lock is also acquired with the ``[p]in`` command to prevent too
many concurrent tasks, breaking the limit.
task: asyncio.Task
The task for the `loop_task` function (`discord.ext.tasks.Loop` object)
task_errors: int
Number of errors that occured within the loop task. If it reaches 5, task is cancelled.
top_8: dict
Represents when the top 8 and bo5 begins in the bracket.
matches_to_announce: List[str]
A list of strings to announce in the defined queue channel. This is done to prevent sending
too many messages at once and hitting ratelimits, so we wrap messages together.
"""
def __init__(
self,
bot: Red,
guild: discord.Guild,
config: Config,
custom_config: str,
name: str,
game: str,
url: str,
id: str,
limit: Optional[int],
status: str,
tournament_start: datetime,
bot_prefix: str,
cog_version: str,
data: dict,
):
self.bot = bot
self.guild = guild
self.data = config
self.config = custom_config
self.name = name
self.game = game
self.url = url
self.id = id
self.limit = limit
self.status = status
self.tz = tournament_start.tzinfo
self.tournament_start = tournament_start
self.bot_prefix = bot_prefix
self.cog_version = cog_version
self.participants: List[Participant] = []
self.matches: List[Match] = []
self.streamers: List[Streamer] = []
self.winner_categories: List[discord.CategoryChannel] = []
self.loser_categories: List[discord.CategoryChannel] = []
self.category: discord.CategoryChannel = guild.get_channel(
data["channels"].get("category")
)
self.announcements_channel: discord.TextChannel = guild.get_channel(
data["channels"].get("announcements")
)
self.checkin_channel: discord.TextChannel = guild.get_channel(
data["channels"].get("checkin")
)
self.queue_channel: discord.TextChannel = guild.get_channel(data["channels"].get("queue"))
self.register_channel: discord.TextChannel = guild.get_channel(
data["channels"].get("register")
)
self.scores_channel: discord.TextChannel = guild.get_channel(
data["channels"].get("scores")
)
self.stream_channel: discord.TextChannel = guild.get_channel(
data["channels"].get("stream")
)
self.to_channel: discord.TextChannel = guild.get_channel(data["channels"].get("to"))
self.lag_channel: discord.TextChannel = guild.get_channel(data["channels"].get("lag"))
self.vip_register_channel: discord.TextChannel = guild.get_channel(
data["channels"].get("vipregister")
)
self.participant_role: discord.Role = guild.get_role(data["roles"].get("participant"))
self.streamer_role: discord.Role = guild.get_role(data["roles"].get("streamer"))
self.to_role: discord.Role = guild.get_role(data["roles"].get("to"))
self.tester_role: discord.Role = guild.get_role(data["roles"].get("tester"))
self.credentials = data["credentials"]
# fitting to achallonge's requirements
self.credentials["login"] = self.credentials.pop("username")
self.credentials["password"] = self.credentials.pop("api")
self.delay: timedelta = timedelta(seconds=data["delay"]) or None
self.time_until_warn: Mapping[str, Tuple[timedelta]] = {
"bo3": tuple(
timedelta(seconds=x) or None
for x in data["time_until_warn"].get("bo3", (1500, 600))
),
"bo5": tuple(
timedelta(seconds=x) or None
for x in data["time_until_warn"].get("bo5", (1800, 600))
),
} # the default values are somehow not loaded into the dict sometimes
self.register: dict = data["register"]
self.checkin: dict = data["checkin"]
self.start_bo5: int = data["start_bo5"]
self.autostop_register: bool = data["autostop_register"]
self.ignored_events = [] # list of scheduled events to skip (register_start/checkin_stop)
# works with next_scheduled_event, used for manual early starts/stops
if data["register"]["second_opening"] != 0:
self.register_second_start: datetime = tournament_start - timedelta(
seconds=data["register"]["second_opening"]
)
else:
self.register_second_start = None
self.ignored_events.append("register_second_start")
if data["register"]["opening"] != 0:
self.register_start: datetime = tournament_start - timedelta(
seconds=data["register"]["opening"]
)
else:
self.register_start = None
self.ignored_events.append("register_start")
if data["register"]["closing"] != 0:
self.register_stop: datetime = tournament_start - timedelta(
seconds=data["register"]["closing"]
)
else:
self.register_stop = None
self.ignored_events.append("register_stop")
if data["checkin"]["opening"] != 0:
self.checkin_start: datetime = tournament_start - timedelta(
seconds=data["checkin"]["opening"]
)
else:
self.checkin_start = None
self.ignored_events.append("checkin_start")
if data["checkin"]["closing"] != 0:
self.checkin_stop: datetime = tournament_start - timedelta(
seconds=data["checkin"]["closing"]
)
else:
self.checkin_stop = None
self.ignored_events.append("checkin_stop")
self.ruleset_channel: discord.TextChannel = guild.get_channel(data["channels"]["ruleset"])
self.game_role: discord.Role = (
guild.get_role(data["roles"]["player"]) or guild.default_role
)
self.baninfo: str = data["baninfo"]
self.ranking: dict = data["ranking"]
self.stages: list = data["stages"]
self.counterpicks: list = data["counterpicks"]
self.phase = "pending" # can be multiple values:
# "pending": initial value, when the tournament is registered
# "register": when registration/checkin is active
# "awaiting": registration is done, participants are ready, waiting for upload and start
# "ongoing": tournament started, also called the "high panic and RAM usage moment"
# "finished": tournament ended. not even sure if it's possible, since tournament is deleted
self.register_phase = "pending" if self.register_start else "manual" # can be:
# "manual": no opening time provided, waiting for user
# "pending": awaiting first registration start
# "ongoing": active, first or second phase, or even manual
# "onhold": done once, but there's still a scheduled opening time (most likely two-stage)
# "done": ended, no more opening scheduled
self.checkin_phase = "pending" if self.checkin_start else "manual"
# same as above (yes "onhold" too, there could be a first manual start)
self.register_message: Optional[discord.Message] = None # message being updated
# list of timedeltas for checkin reminders, associated to whether we should send DMs or not
# timedeltas are substracted from checkin_end (ex: 10 min before checkin ending)
self.checkin_reminders: List[Tuple[int, bool]] = []
# loop task things
self.lock = asyncio.Lock()
self.task: Optional[asyncio.Task] = None
self.task_errors = 0
self.top_8 = {
"winner": {"top8": None, "bo5": None},
"loser": {"top8": None, "bo5": None},
}
# self.debug_task = asyncio.get_event_loop().create_task(self.debug_loop_task())
self.matches_to_announce: List[str] = [] # matches to announce in the queue channel
self.cancelling = False # see Tournament.__del__ and Match.__del__
# 5 min cooldown on ranking fetch (use saved one instead)
self.last_ranking_fetch: Optional[datetime] = None
def __repr__(self):
return (
"<Tournament name={0.name} phase={0.phase} status={0.status} url={0.url} "
"game={0.game} limit={0.limit} id={0.id} guild_id={0.guild.id}>"
).format(self)
participant_object = Participant
match_object = Match
tournament_type = "base" # should be "challonge", or "smash.gg"...
[docs] def cancel(self):
"""
Correctly clears the object, stopping the task and removing ranking data.
"""
self.cancelling = True
if self.task:
self.stop_loop_task()
# try:
# self.debug_task.cancel()
# except AttributeError:
# pass
shutil.rmtree(
cog_data_path(None, raw_name="Tournaments") / "ranking" / str(self.guild.id),
ignore_errors=True,
)
def __del__(self):
self.cancel()
# Config-related stuff
[docs] @classmethod
async def from_saved_data(
cls,
bot: Red,
guild: discord.Guild,
config: Config,
cog_version: str,
data: dict,
config_data: dict,
):
"""
Loads a tournament from Config.
Due to Python's weird behaviour, this method must be reimplemented and simply called back
without changes.
"""
tournament_start = datetime.fromtimestamp(
int(data["tournament_start"][0]),
tz=timezone(timedelta(seconds=data["tournament_start"][1])),
)
custom_config = data.pop("config")
participants = data.pop("participants")
matches = data.pop("matches")
streamers = data.pop("streamers")
winner_categories = data.pop("winner_categories")
loser_categories = data.pop("loser_categories")
phase = data.pop("phase")
register = data.pop("register")
checkin = data.pop("checkin")
ignored_events = data.pop("ignored_events")
register_message_id = data.pop("register_message_id")
checkin_reminders = data.pop("checkin_reminders")
del data["tournament_start"], data["tournament_type"]
tournament = cls(
bot,
guild,
config,
custom_config,
**data,
tournament_start=tournament_start,
cog_version=cog_version,
data=config_data,
)
if phase == "ongoing":
await tournament._get_top8()
tournament.participants = list(
filter(
None,
[
tournament.participant_object.from_saved_data(tournament, data)
for data in participants
],
)
)
if len(tournament.participants) < len(participants):
await tournament._update_participants_list()
if not tournament.participants and matches:
raise RuntimeError(
"Participants list is empty while there are ongoing matches! "
"We probably don't want to continue resuming the tournament, as this "
"could result in the disqualification of absolutely everyone."
)
for data in matches:
player1 = tournament.find_participant(player_id=data["player1"])[1]
player2 = tournament.find_participant(player_id=data["player2"])[1]
match = tournament.match_object.from_saved_data(tournament, player1, player2, data)
if player1 is None and player2 is None:
if match.channel:
await tournament.destroy_player(data["player1"])
await tournament.destroy_player(data["player2"])
try:
await match.channel.delete()
except discord.HTTPException as e:
log.warn(
f"[Guild {guild.id}] Can't delete set channel #{match.set}.",
exc_info=e,
)
await tournament.to_channel.send(
_(":information_source: Set {match} cancelled, both players left.").format(
set=match.set
)
)
continue
stop = False
for i, player in enumerate((player1, player2), start=1):
if player is None:
await tournament.destroy_player(data[f"player{i}"])
await match.disqualify(data[f"player{i}"])
await tournament.to_channel.send(
_(
":information_source: Set {set} finished, "
"player with ID {player} can't be found."
).format(set=match.set, player=data[f"player{i}"])
)
stop = True
if stop:
continue
tournament.matches.append(match)
tournament.streamers = [Streamer.from_saved_data(tournament, x) for x in streamers]
tournament.winner_categories = list(
filter(None, [guild.get_channel(x) for x in winner_categories])
)
tournament.loser_categories = list(
filter(None, [guild.get_channel(x) for x in loser_categories])
)
tournament.phase = phase
tournament.register_phase = register
tournament.checkin_phase = checkin
tournament.ignored_events = ignored_events
tournament.checkin_reminders = checkin_reminders
if register_message_id and tournament.register_channel:
try:
message = await tournament.register_channel.fetch_message(register_message_id)
except discord.NotFound:
pass
else:
tournament.register_message = message
return tournament
[docs] def to_dict(self) -> dict:
"""Returns a dict ready for Config."""
offset = self.tournament_start.utcoffset()
if offset:
offset = offset.total_seconds()
else:
offset = 0
data = {
"config": self.config,
"name": self.name,
"game": self.game,
"url": self.url,
"id": self.id,
"limit": self.limit,
"status": self.status,
"tournament_start": (int(self.tournament_start.timestamp()), offset),
"bot_prefix": self.bot_prefix,
"participants": [x.to_dict() for x in self.participants],
"matches": [x.to_dict() for x in self.matches],
"streamers": [x.to_dict() for x in self.streamers],
"winner_categories": [x.id for x in self.winner_categories],
"loser_categories": [x.id for x in self.loser_categories],
"phase": self.phase,
"tournament_type": self.tournament_type,
"register": self.register_phase,
"checkin": self.checkin_phase,
"checkin_reminders": self.checkin_reminders,
"ignored_events": self.ignored_events,
"register_message_id": self.register_message.id if self.register_message else None,
}
return data
[docs] async def save(self):
"""
Saves data with Config. This is done with the loop task during a tournament but must be
called while it's not ongoing.
"""
data = self.to_dict()
await self.data.guild(self.guild).tournament.set(data)
@property
def allowed_roles(self):
"""
Return a list of roles that should have access to the temporary channels.
"""
allowed_roles = []
if self.to_role is not None:
allowed_roles.append(self.to_role)
if self.streamer_role is not None:
allowed_roles.append(self.streamer_role)
if self.tester_role is not None:
allowed_roles.append(self.tester_role)
return allowed_roles
# some common utils
@staticmethod
def _format_datetime(date: datetime, only_time=False):
locale = get_babel_locale()
_date = format_date(date, format="full", locale=locale)
time = format_time(date, format="short", locale=locale)
if only_time:
return time
return _("{date} at {time}").format(date=_date, time=time)
[docs] def next_scheduled_event(self) -> Tuple[str, timedelta]:
"""
Returns the next scheduled event (register/checkin/None) with the corresponding timedelta
"""
now = datetime.now(self.tz)
# the order here is important, as max() return the first highest value
# it's pretty common to have some events with the same datetime, and the loop only runs
# one event per iteration, so we place them in a logical order here
events = {
"register_start": (self.register_start, self.register_phase == "pending"),
"checkin_stop": (self.checkin_stop, self.checkin_phase == "ongoing"),
"checkin_start": (self.checkin_start, self.checkin_phase == "pending"),
"register_second_start": (self.register_second_start, self.register_phase == "onhold"),
"register_stop": (self.register_stop, self.register_phase == "ongoing"),
}
for name, (date, condition) in events.items():
if date is None:
events[name] = None
continue
if name in self.ignored_events:
events[name] = None
continue
delta = date - now
if condition is True:
events[name] = delta
else:
events[name] = None
events = dict(filter(lambda x: x[1] is not None, events.items()))
return min(events.items(), key=lambda x: x[1], default=None)
def _valid_dates(self):
now = datetime.now(self.tz)
dates = [
(_("Registration start"), self.register_start, "register_start"),
(_("Registration second start"), self.register_second_start, "register_second_start"),
(_("Registration stop"), self.register_stop, "register_stop"),
(_("Check-in start"), self.checkin_start, "checkin_start"),
(_("Check-in stop"), self.checkin_stop, "checkin_stop"),
]
passed = [(x, y, z) for x, y, z in dates if y and now > y]
if passed:
raise RuntimeError(_("Some dates are passed."), passed)
if (
self.register_start
and self.register_stop
and not self.register_start < self.register_stop
):
dates = [dates[0] + dates[2]]
raise RuntimeError(_("Registration start and stop times conflict."), dates)
if self.register_second_start and (
(self.register_start and not self.register_start < self.register_second_start)
or (self.register_stop and not self.register_second_start < self.register_stop)
):
dates = dates[:3]
raise RuntimeError(_("Second registration start time conflict."), dates)
if self.checkin_start and self.checkin_stop and not self.checkin_start < self.checkin_stop:
dates = dates[3:]
raise RuntimeError(_("Check-in start and stop times conflict."), dates)
async def _get_available_category(self, dest: str, inc: int = 0):
position = self.category.position + 1 if self.category else len(self.guild.categories)
if dest == "winner":
categories = self.winner_categories
else:
categories = self.loser_categories
position += len(categories)
try:
return next(filter(lambda x: len(x.channels) + inc < 50, categories))
except StopIteration:
if dest == "winner":
name = "Winner bracket"
else:
name = "Loser bracket"
channel = await self.guild.create_category(
name, reason=_("New category of sets."), position=position
)
await channel.edit(position=position) # discord won't let me place it on first try
if dest == "winner":
self.winner_categories.append(channel)
else:
self.loser_categories.append(channel)
return channel
async def _clear_categories(self):
categories = self.winner_categories + self.loser_categories
for category in categories:
try:
await category.delete()
except discord.HTTPException as e:
log.error(
f"[Guild {self.guild.id}] Can't delete category {category} "
"(_clear_categories was called).",
exc_info=e,
)
self.winner_categories = []
self.loser_categories = []
async def _get_top8(self):
# if you're wondering how this works, well I have no idea :D
# this was mostly taken from the original bot this cog is based on, ATOS by Wonderfall
# https://github.com/Wonderfall/ATOS/blob/master/bot.py#L1355-L1389
rounds = await self._get_all_rounds()
if not rounds:
raise RuntimeError("There are no matches available.")
top8 = self.top_8
# calculate top 8
top8["winner"]["top8"] = max(rounds) - 2
top8["loser"]["top8"] = min(rounds) + 2
# minimal values, in case of a small tournament
if top8["winner"]["top8"] < 1:
top8["winner"]["top8"] = 1
if top8["loser"]["top8"] > -1:
top8["loser"]["top8"] = -1
# calculate bo5 start
if self.start_bo5 > 0:
top8["winner"]["bo5"] = top8["winner"]["top8"] + self.start_bo5 - 1
elif self.start_bo5 in (0, 1):
top8["winner"]["bo5"] = top8["winner"]["top8"] + self.start_bo5
else:
top8["winner"]["bo5"] = top8["winner"]["top8"] + self.start_bo5 + 1
if self.start_bo5 > 1:
top8["loser"]["bo5"] = min(rounds) # top 3 is loser final anyway
else:
top8["loser"]["bo5"] = top8["loser"]["top8"] - self.start_bo5
# avoid aberrant values
if top8["winner"]["bo5"] > max(rounds):
top8["winner"]["bo5"] = max(rounds)
if top8["winner"]["bo5"] < 1:
top8["winner"]["bo5"] = 1
if top8["loser"]["bo5"] < min(rounds):
top8["loser"]["bo5"] = min(rounds)
if top8["loser"]["bo5"] > -1:
top8["loser"]["bo5"] = -1
[docs] async def warn_bracket_change(self, *sets):
"""
Warn T.O.s of a bracket change.
Parameters
----------
*sets: str
The list of affected sets.
"""
await self.to_channel.send(
_(
":information_source: Changes were detected on the upstream bracket.\n"
"This may result in multiple sets ending, relaunch or cancellation.\n"
"Affected sets: {sets}"
).format(sets=", ".join([f"#{x}" for x in sets]))
)
# tools for finding objects within the instance's lists of Participants, Matches and Streamers
[docs] def find_participant(
self,
*,
player_id: Optional[str] = None,
discord_id: Optional[int] = None,
discord_name: Optional[str] = None,
) -> Tuple[int, Participant]:
"""
Find a participant in the internal cache, and returns its object and position in the list.
You need to provide only one of the parameters.
Parameters
----------
player_id: Optional[str]
Player's ID on the bracket, as returned by `Participant.player_id`
discord_id: Optional[int]
Player's Discord ID
discord_name: Optional[str], as returned by `discord.Member.id`
Player's full Discord name, as returned by ``str(discord.Member)``
Returns
-------
Tuple[int, Participant]
The index of the participant in the list (useful for deletion or deplacement) and
its `Participant` object
Raises
------
RuntimeError
No parameter was provided
"""
if player_id:
try:
return next(
filter(lambda x: x[1].player_id == player_id, enumerate(self.participants))
)
except StopIteration:
return None, None
elif discord_id:
try:
return next(filter(lambda x: x[1].id == discord_id, enumerate(self.participants)))
except StopIteration:
return None, None
elif discord_name:
try:
return next(
filter(lambda x: str(x[1]) == discord_name, enumerate(self.participants))
)
except StopIteration:
return None, None
raise RuntimeError("Provide either player_id, discord_id or discord_name")
[docs] def find_match(
self,
*,
match_id: Optional[int] = None,
match_set: Optional[int] = None,
channel_id: Optional[int] = None,
) -> Tuple[int, Match]:
"""
Find a match in the internal cache, and returns its object and position in the list.
You need to provide only one of the parameters.
Parameters
----------
match_id: Optional[int]
Match's ID on the bracket, as returned by `Match.id`
match_set: Optional[int]
Match's number, or suggested play order, on the bracket, as returned by `Match.set`
channel_id: Optional[id]
Discord channel's ID, as returned by `discord.TextChannel.id`
.. warning:: A match may not have a channel assigned
Returns
-------
Tuple[int, Match]
The index of the match in the list (useful for deletion or deplacement) and
its `Match` object
Raises
------
RuntimeError
No parameter was provided
"""
if match_id:
try:
return next(filter(lambda x: x[1].id == match_id, enumerate(self.matches)))
except StopIteration:
return None, None
elif match_set:
try:
return next(filter(lambda x: x[1].set == match_set, enumerate(self.matches)))
except StopIteration:
return None, None
elif channel_id:
try:
return next(
filter(
lambda x: x[1].channel and x[1].channel.id == channel_id,
enumerate(self.matches),
)
)
except StopIteration:
return None, None
raise RuntimeError("Provide either match_id, match_set or channel_id")
[docs] def find_streamer(
self, *, channel: Optional[str] = None, discord_id: Optional[int] = None
) -> Tuple[int, Streamer]:
"""
Find a streamer in the internal cache, and returns its object and position in the list.
You need to provide only one of the parameters.
Parameters
----------
channel: Optional[str]
The streamer's channel, as returned by `Streamer.channel` (not full URL, only last
part). Example for https://twitch.tv/dreekius, use ``channel="dreekius"``.
discord_id: Optional[int]
Streamer's Discord ID
Returns
-------
Tuple[int, Streamer]
The index of the streamer in the list (useful for deletion or deplacement) and
its `Streamer` object
Raises
------
RuntimeError
No parameter was provided
"""
if channel:
try:
return next(filter(lambda x: x[1].channel == channel, enumerate(self.streamers)))
except StopIteration:
return None, None
elif discord_id:
try:
return next(
filter(lambda x: x[1].member.id == discord_id, enumerate(self.streamers))
)
except StopIteration:
return None, None
raise RuntimeError("Provide either channel or discord_id")
# registration and check-in related methods
def _prepare_register_message(self):
if self.checkin_start:
checkin = _(":white_small_square: __Check-in:__ From {begin} to {end}\n").format(
begin=self._format_datetime(self.checkin_start, True),
end=self._format_datetime(self.checkin_stop or self.tournament_start, True),
)
else:
checkin = ""
if self.limit:
limit = _("{registered}/{limit} participants registered").format(
registered=len(self.participants), limit=self.limit
)
else:
limit = _("{registered} participants registered *(no limit set)*").format(
registered=len(self.participants)
)
if self.ruleset_channel:
ruleset = _(":white_small_square: __Ruleset:__ See {channel}\n").format(
channel=self.ruleset_channel.mention
)
else:
ruleset = ""
return _(
"**{t.name}** | *{t.game}*\n\n"
":white_small_square: __Date:__ {date}\n"
":white_small_square: __Register:__ Closing at {time}\n"
"{checkin}"
":white_small_square: __Participants:__ {limit}\n"
":white_small_square: __Bracket:__ {t.url}\n"
"{ruleset}\n"
"You can register/unregister to this tournament with "
"the `{t.bot_prefix}in` and `{t.bot_prefix}out` commands.\n"
"*Note: your Discord username will be used in the bracket.*"
).format(
t=self,
date=self._format_datetime(self.tournament_start),
time=self._format_datetime(self.register_stop or self.tournament_start, True),
checkin=checkin,
limit=limit,
ruleset=ruleset,
)
[docs] async def start_registration(self, second=False):
"""
Open the registrations and save.
Parameters
----------
second: bool
If this is the second time registrations are started (will not annouce the same
message, and keep updating the same pinned message). Defaults to `False`.
"""
self.phase = "register"
self.register_phase = "ongoing"
if self.register_channel:
if not second:
self.register_message = await self.register_channel.send(
self._prepare_register_message()
)
await self.register_message.pin()
await self.register_channel.set_permissions(
self.game_role, read_messages=True, send_messages=True
)
if self.announcements_channel:
if second:
message = _(
"{role} Registrations for the tournament **{tournament}** "
"are now re-opened{channel} until {date}!"
).format(
role=self.game_role.mention
if self.game_role != self.guild.default_role
else "",
tournament=self.name,
channel=_(" in {channel}").format(channel=self.register_channel.mention)
if self.register_channel
else "",
date=self._format_datetime(self.register_stop or self.tournament_start),
)
elif self.register_channel:
message = _(
"{role} Registrations for the tournament **{tournament}** are now opened "
"in {channel}! See the pinned message there for details.\n"
":calendar_spiral: This tournament will take place on **{date}**."
).format(
tournament=self.name,
channel=self.register_channel.mention,
role=self.game_role.mention
if self.game_role != self.guild.default_role
else "",
date=self._format_datetime(self.tournament_start),
)
else:
if self.checkin_start:
checkin = _(
":white_small_square: __Check-in:__ From {begin} to {end}\n"
).format(
begin=self._format_datetime(self.checkin_start, True),
end=self._format_datetime(
self.checkin_stop or self.tournament_start, True
),
)
else:
checkin = ""
if self.limit:
limit = _("Limited to {limit} particiapants.").format(
registered=len(self.participants), limit=self.limit
)
else:
limit = _("No limit set.")
if self.ruleset_channel:
ruleset = _(":white_small_square: __Ruleset:__ See {channel}\n").format(
channel=self.ruleset_channel.mention
)
else:
ruleset = ""
message = _(
"{role} Registrations for the tournament **{t.name}** are now opened!\n"
":calendar_spiral: This tournament will take place on **{date}**.\n\n"
":white_small_square: __Register:__ Closing at {time}\n"
"{checkin}"
":white_small_square: __Participants:__ {limit}\n"
":white_small_square: __Bracket:__ {t.url}\n"
"{ruleset}\n"
"You can register/unregister to this tournament with "
"the `{t.bot_prefix}in` and `{t.bot_prefix}out` commands.\n"
"*Note: your Discord username will be used in the bracket.*"
).format(
t=self,
role=self.game_role.mention
if self.game_role != self.guild.default_role
else "",
date=self._format_datetime(self.tournament_start),
time=self._format_datetime(self.register_stop or self.tournament_start, True),
register=self._format_datetime(self.register_start or self.tournament_start),
checkin=checkin,
limit=limit,
ruleset=ruleset,
)
mentions = discord.AllowedMentions(roles=[self.game_role]) if self.game_role else None
await self.announcements_channel.send(message, allowed_mentions=mentions)
await self.save()
[docs] async def end_registration(self):
"""
Close the registrations and save.
If the check-in is also done, participants will be seeded and uploaded.
Parameters
----------
background: bool
If the function is called in a background loop. If `True`, the bot will do actions
knowing there's no context command (for now, means a background seed and upload).
Defaults to `False`.
"""
if self.register_second_start and self.register_second_start > datetime.now(self.tz):
self.register_phase = "onhold"
else:
self.register_phase = "done"
if self.register_channel:
if self.register_message:
await self.register_message.edit(content=self._prepare_register_message())
await self.register_channel.set_permissions(
self.game_role, read_messages=True, send_messages=False
)
await self.register_channel.send(_("Registration ended."))
elif self.announcements_channel: # no registration channel, so we announce somewhere else
await self.announcements_channel.send(_("Registration ended."))
if not self.next_scheduled_event():
# no more scheduled events, upload and wait for start
self.phase = "awaiting"
await self._background_seed_and_upload()
await self.save()
[docs] async def start_check_in(self):
"""
Open the check-in and save.
This will also calculate and fill the `checkin_reminders` list.
"""
if not self.participants:
self.checkin_phase = "done"
message = _("Cancelled check-in start since there are currently no participants. ")
if not self.next_scheduled_event:
# no more scheduled events, upload and wait for start
self.phase = "awaiting"
else:
message += _(
"Registrations are still ongoing, and new participants are pre-checked."
)
await self.to_channel.send(message)
return
self.phase = "register"
self.checkin_phase = "ongoing"
message = _(
"{role} The check-in for **{t.name}** has started!\n"
"You have to confirm your presence by typing `{t.bot_prefix}in` here{end_time}.\n"
"If you want to unregister, type `{t.bot_prefix}out` instead.\n\n"
":warning: If you don't check in time, you will be unregistered!"
).format(
t=self,
role=self.participant_role.mention,
end_time=_(" until {}").format(
self._format_datetime(self.checkin_stop, only_time=True)
)
if self.checkin_stop
else "",
)
mentions = discord.AllowedMentions(roles=[self.participant_role])
if self.checkin_channel:
message = await self.checkin_channel.send(message, allowed_mentions=mentions)
await message.pin()
await self.checkin_channel.set_permissions(
self.participant_role, read_messages=True, send_messages=True
)
elif self.announcements_channel:
await self.announcements_channel.send(message, allowed_mentions=mentions)
if self.register_channel and self.register_phase == "ongoing":
await self.register_channel.send(
_(
":information_source: Check-in started{channel}!\n"
"You can still register here until {end_time}. "
"Anyone registering as of now will already be checked in."
).format(
channel=_(" in {}").format(self.checkin_channel.mention)
if self.checkin_channel
else "",
end_time=self._format_datetime(
self.register_stop or self.tournament_start, only_time=True
),
)
)
if self.checkin_stop:
duration = (self.checkin_stop - datetime.now(self.tz)).total_seconds()
if duration < 60:
# don't start the check-in only to end it within a minute
self.ignored_events.append("checkin_stop")
else:
duration //= 60 # number of minutes
if duration >= 10:
self.checkin_reminders.append((5, False))
if duration >= 20:
self.checkin_reminders.append((10, True))
if duration >= 40:
self.checkin_reminders.append((15, False))
await self.save()
[docs] async def call_check_in(self, with_dm: bool = False):
"""
Pings participants that have not checked in yet.
Only works with a check-in channel setup and a stop time.
Parameters
----------
with_dm: bool
If the bot should DM unchecked members too. Defaults to `False`.
.. caution:: Prevent using this if there are too many unchecked members, as Discord
started to ban bots sending too many DMs.
"""
if not self.checkin_channel:
return
if not self.checkin_stop:
return
members = [x for x in self.participants if not x.checked_in]
if not members:
return
await self.checkin_channel.send(
_(
":clock1: **Check-in reminder!**\n\n- {members}\n\n"
"You have until {end_time} to check-in, or you'll be unregistered."
).format(
members="\n- ".join([x.mention for x in members]),
end_time=self._format_datetime(self.checkin_stop, only_time=True),
)
)
if with_dm:
for member in members:
try:
await member.send(
_(
":warning: **Attention!** You have **{time} minutes** left "
"for checking-in to the tournament **{tournament}**."
).format(
time=round(
(self.checkin_stop - datetime.now(self.tz)).total_seconds() / 60
),
tournament=self.name,
)
)
except discord.HTTPException:
pass
[docs] async def end_checkin(self):
"""
Close the check-in, unregister unchecked members (attempts to DM) and save.
If the registrations are also done, participants will be seeded and uploaded.
Parameters
----------
background: bool
If the function is called in a background loop. If `True`, the bot will do actions
knowing there's no context command (for now, means a background seed and upload).
Defaults to `False`.
"""
self.checkin_phase = "done"
to_remove = []
failed = []
for member in filter(lambda x: x.checked_in is False, self.participants):
try:
await member.remove_roles(
self.participant_role, reason=_("Participant not checked.")
)
except discord.HTTPException as e:
log.warn(
f"[Guild {self.guild.id}] Can't remove participant role from unchecked "
f"participant {member} (ID: {member.id}) after check-in end.",
exc_info=e,
)
failed.append(member)
try:
await member.send(
_(
"You didn't check-in on time. "
"You are therefore unregistered from the tournament **{tournament}**."
).format(tournament=self.name)
)
except discord.HTTPException:
pass
to_remove.append(member)
for member in to_remove:
await self.unregister_participant(member, send_dm=False)
text = _(":information_source: Check-in was ended. {removed}").format(
removed=_("{} participants didn't check and were unregistered.").format(len(to_remove))
if to_remove
else _("No participant was unregistered.")
)
if failed:
text += _("\n\n:warning: {} participants couldn't have their roles removed:\n")
text += " ".join([x.mention for x in failed])
for page in pagify(text):
await self.to_channel.send(page)
if self.checkin_channel:
await self.checkin_channel.set_permissions(
self.game_role, read_messages=True, send_messages=False
)
await self.checkin_channel.send(
_("Check-in ended. Participants who didn't check are unregistered.")
)
elif self.announcements_channel:
await self.announcements_channel.send(
_("Check-in ended. Participants who didn't check are unregistered.")
)
if not self.next_scheduled_event:
# no more scheduled events, upload and wait for start
self.phase = "awaiting"
await self._background_seed_and_upload()
await self.save()
[docs] async def register_participant(self, member: discord.Member, send_dm: bool = True):
"""
Register a new participant to the tournament (add role) and save.
If the check-in has started, participant will be pre-checked.
If there is a limit of participants, the auto-stop setting for registrations is enabled and
the limit is reached, registrations will be closed.
The `Participant` object is not returned and directly added to the list.
Parameters
----------
member: discord.Member
The member to register. They must be in the server.
send_dm: bool
If the bot should DM the new participant for their registrations. Defaults to `True`.
"""
if self.limit and len(self.participants) >= self.limit:
raise RuntimeError("Limit reached.")
await member.add_roles(self.participant_role, reason=_("Registering to tournament."))
participant = self.participant_object(member, self)
if self.checkin_phase == "ongoing" or self.checkin_phase == "done":
# registering during or after check-in, count as already checked
participant.checked_in = True
if not (self.ranking["league_name"] and self.ranking["league_id"]) or (
self.participants and self.participants[-1].player_id is not None
):
# either there's no ranking, in which case we always upload on register, or
# last registered participant has a player ID, so we should upload them to the bracket
# first we seed them, if possible
try:
await self.seed_participants()
except Exception:
pass # any exception will roll back the list of participants so we're safe
await self.add_participant(participant)
self.participants.append(participant)
log.debug(f"[Guild {self.guild.id}] Player {member} registered.")
if (
self.limit
and self.autostop_register
and self.register_phase == "ongoing"
and len(self.participants) >= self.limit
):
await self.end_registration()
await self.save()
if not send_dm:
return
try:
await member.send(
_("You are now registered to the tournament **{name}**!").format(name=self.name)
)
except discord.HTTPException:
pass
[docs] async def unregister_participant(self, member: discord.Member, send_dm: bool = True):
"""
Remove a participant.
If the player is uploaded on the bracket, they will also be removed from there. If the
tournament has started, member will be disqualified instead.
This removes roles and DMs the participant.
Parameters
----------
member: discord.Member
The member to disqualify
Raise
-----
KeyError
The member is not registered
"""
i, participant = self.find_participant(discord_id=member.id)
if i is None:
raise KeyError("Participant not found.")
if participant.player_id is not None:
await participant.destroy()
if participant.match is not None:
await participant.match.disqualify(participant)
await participant.remove_roles(
self.participant_role, reason=_("Unregistering from tournament.")
)
del self.participants[i]
await self.save()
if not send_dm:
return
try:
await participant.send(_("You were unregistered from the tournament."))
except discord.Forbidden:
pass
# seeding stuff
# 95% of this code is made by Wonderfall, from ATOS bot (original)
# https://github.com/Wonderfall/ATOS/blob/master/utils/seeding.py
async def _fetch_braacket_ranking_info(self):
if self.last_ranking_fetch and self.last_ranking_fetch + timedelta(
minutes=5
) < datetime.now(self.tz):
# 5 min cooldown on ranking fetch
return
league_name, league_id = self.ranking["league_name"], self.ranking["league_id"]
headers = {
"User-Agent": (
f"Red-DiscordBot {red_version} Laggrons-Dumb-Cogs/tournaments {self.cog_version}"
),
"Connection": "close",
}
path = cog_data_path(None, raw_name="Tournaments") / "ranking" / str(self.guild.id)
path.mkdir(parents=True, exist_ok=True)
async with aiohttp.ClientSession(headers=headers) as session:
for page in range(1, 6):
url = f"https://braacket.com/league/{league_name}/ranking/{league_id}"
parameters = {
"rows": 200,
"page": page,
"export": "csv",
}
file_path = path / f"page{page}.csv"
async with session.get(url, params=parameters) as response:
if response.status >= 400:
raise RuntimeError(response.status, response.reason)
async with aiofiles.open(file_path, mode="wb") as file:
await file.write(await response.read())
if page != 1 and filecmp.cmp(file_path, path / f"page{page-1}.csv"):
await aiofiles.os.remove(file_path)
break
self.last_ranking_fetch = datetime.now(self.tz)
async def _seed_participants(self):
ranking = {}
path = cog_data_path(None, raw_name="Tournaments") / "ranking"
# open and parse the previously downloaded CSV
for file in list(path.rglob("*.csv")):
with open(file, errors="surrogateescape") as f:
reader = csv.DictReader(f)
for row in reader:
ranking[row["Player"]] = int(row["Points"])
# base elo : put at bottom
base_elo = min(list(ranking.values()))
ranked = []
not_ranked = []
# assign elo ranking to each player
for player in self.participants:
try:
player.elo = ranking[str(player)]
ranked.append(player)
except KeyError:
player.elo = base_elo # base Elo if none found
not_ranked.append(player)
# Sort & clean
shuffle(not_ranked)
sorted_participants = sorted(ranked, key=lambda x: x.elo, reverse=True)
self.participants = sorted_participants + not_ranked
[docs] async def seed_participants(self, remove_unchecked: bool = False):
"""
Seed the participants if ranking info is configured.
.. warning:: If an exception occurs, the list of participants will be rolled back to its
previous state, then the error propagates.
Parameters
----------
remove_unchecked: bool
If unchecked members should be removed from the internal list and the upload. Defaults
to `False`
"""
prev_participants = copy(self.participants)
try:
if self.ranking["league_name"] and self.ranking["league_id"]:
await self._fetch_braacket_ranking_info()
await self._seed_participants()
if remove_unchecked is True:
self.participants = [x for x in self.participants if x.checked_in]
except Exception:
# roll back to a previous state
self.participants = prev_participants
raise
async def _background_seed_and_upload(self):
"""
Run self.seed_participants_and_upload, but catch and log exceptions to the to channel.
Used when it needs to be ran in the background (register/checkin automated close, no
context), because an issue with this needs to be told.
"""
try:
await self.seed_participants()
await self.add_participants()
except Exception as e:
log.error(
f"[Guild {self.guild.id}] Can't seed and upload participants (background).",
exc_info=e,
)
await self.to_channel.send(
_(
":warning: An issue occured when trying to seed and upload participants "
"after registration/checkin close. Try running it manually with `{prefix}"
"upload`, and contact admins if the issue persists."
).format(prefix=self.bot_prefix)
)
# starting the tournament...
[docs] async def send_start_messages(self):
"""
Send the required messages when starting the tournament.
Depending on the configured channels, announcements will be sent in:
* The announcements channel
* The scores channel
* The queue channel
"""
scores_channel = (
_(" in {channel}").format(channel=self.scores_channel.mention)
if self.scores_channel
else ""
)
messages = {
self.announcements_channel: _(
"The tournament **{tournament}** has started! Bracket: {bracket}\n"
":white_small_square: You can access it "
"anytime with the `{prefix}bracket` command.\n"
":white_small_square: You can check the current "
"streams with the `{prefix}streams` command.\n\n"
"{participant} Please read the instructions :\n"
"{queue_channel}"
"{rules_channel}"
":white_small_square: The winner of a set must report the score **as soon as "
"possible**{scores_channel} with the `{prefix}win` command.\n"
":white_small_square: You can disqualify from the tournament with the "
"`{prefix}dq` command, or just abandon your current set with the `{prefix}ff` "
"command.\n"
":white_small_square: In case of lag making the game unplayable, use the `{prefix}"
"lag` command to call the T.O.\n"
"{delay}."
).format(
tournament=self.name,
bracket=self.url,
participant=self.participant_role.mention,
queue_channel=_(
":white_small_square: Your sets are announced in {channel}.\n"
).format(channel=self.queue_channel.mention)
if self.queue_channel
else "",
rules_channel=_(
":white_small_square: The ruleset is available in {channel}.\n"
).format(channel=self.ruleset_channel.mention)
if self.ruleset_channel
else "",
scores_channel=scores_channel,
delay=_(
":timer: **You will automatically be disqualified if you don't talk in your "
"channel within the first {delay}.**"
).format(delay=humanize_timedelta(timedelta=self.delay))
if self.delay
else "",
prefix=self.bot_prefix,
),
self.scores_channel: _(
":information_source: Management of the scores for the "
"tournament **{tournament}** is automated:\n"
":white_small_square: Only **the winner of the set** "
"sends their score with the `{prefix}win` command.\n"
":white_small_square: You must follow this "
"format: `{prefix}win 2-0, 3-2, 3-1, ...`.\n"
":white_small_square: Look at the bracket to **check** the informations: {url}\n"
":white_small_square: In case of a wrong input, contact a T.O. for a manual fix."
).format(tournament=self.name, url=self.url, prefix=self.bot_prefix),
self.queue_channel: _(
":information_source: **Set launch is automated.** "
"Please follow the instructions in this channel.\n"
":white_small_square: Any streamed set will be "
"announced here, and in your channel.\n"
":white_small_square: Any BO5 set will be precised here and in your channel.\n"
":white_small_square: The player beginning the bans is picked and "
"annonced in your channel (you can also use `{prefix}flip`).\n\n{dq}"
).format(
prefix=self.bot_prefix,
dq=_(
":timer: **You will be disqualified if you were not active in your channel** "
"within {delay} after the set launch."
).format(delay=humanize_timedelta(timedelta=self.delay))
if self.delay
else "",
),
}
for channel, message in messages.items():
if channel is None:
continue
try:
await channel.send(message)
except discord.HTTPException as e:
log.error(f"[Guild {self.guild.id}] Can't send message in {channel}.", exc_info=e)
# now this is the loop task stuff, the one that runs during the tournament (not other phases)
[docs] async def announce_sets(self):
"""
Wraps the messages stored in `matches_to_announce` and sends them in the `queue_channel`.
"""
if not self.queue_channel:
return
message = ""
for match in self.matches_to_announce:
message += match + "\n"
self.matches_to_announce = []
for page in pagify(message):
await self.queue_channel.send(
page, allowed_mentions=discord.AllowedMentions(users=False)
)
[docs] async def launch_sets(self):
"""
Launch pending matches, creating a channel and marking the match as ongoing.
This only launches 20 matches max.
This is wrapped inside `asyncio.gather`, so errors will not propagate.
"""
match: Match
coros = []
# islice will limit the output to 20. see this as list[:20] but with a generator
for i, match in enumerate(
islice(filter(lambda x: x.status == "pending" and x.channel is None, self.matches), 20)
):
# we get the category in the iteration instead of the gather
# because if all functions call _get_available_category at the same time,
# a new category will be returned for each
bracket = "winner" if match.round > 0 else "loser"
category = await self._get_available_category(bracket, i)
coros.append(match.launch(category=category))
if not coros:
return
results = await asyncio.gather(*coros, return_exceptions=True)
for result in filter(None, results):
log.error(f"[Guild {self.guild.id}] Can't launch a set.", exc_info=result)
await self.announce_sets()
[docs] def update_streamer_list(self):
"""
Update the internal streamer's list (next stream attr)
"""
for streamer in self.streamers:
streamer._update_list()
[docs] async def launch_streams(self):
"""
Launch the streams (call the next matches in the streamer's queue).
You must call `update_streamer_list` first.
"""
match: Match
for match in filter(lambda x: x.streamer and x.on_hold, self.matches):
if match.streamer.current_match and match.streamer.current_match.id == match.id:
await match.start_stream()
[docs] async def check_for_channel_timeout(self):
"""
Look through the ongoing/finished matches and compare durations to see if AFK check or
channel deletion is required, and proceed.
"""
match: Match
for i, match in filter(
lambda x: x[1].status != "pending" and x[1].channel is not None,
enumerate(self.matches),
):
if self.delay and match.status == "ongoing":
if not match.checked_dq and match.duration > self.delay:
log.debug(f"Checking inactivity for match {match.set}")
await match.check_inactive()
elif match.status == "finished":
if match.channel and (match.end_time + timedelta(minutes=5)) < datetime.now(
self.tz
):
log.debug(f"Checking deletion for match {match.set}")
try:
await match.channel.delete(reason=_("5 minutes passed after set end."))
except discord.HTTPException as e:
log.warn(
f"[Guild {self.guild.id}] Can't delete set channel #{match.set}.",
exc_info=e,
)
del self.matches[i]
[docs] async def check_for_too_long_matches(self):
"""
Look through the ongoing matches and verifies the duration. Warn if necessary.
"""
match: Match
for match in filter(
lambda x: x.status == "ongoing" and x.channel and not x.on_hold and x.streamer is None,
self.matches,
):
max_length = self.time_until_warn["bo5" if match.is_bo5 else "bo3"]
if match.warned is True:
continue
if not max_length[0]:
continue
if match.warned is None:
if match.duration > max_length[0]:
await match.warn_length()
elif max_length[1] and datetime.now(self.tz) > match.warned + max_length[1]:
await match.warn_to_length()
async def _loop_task(self):
if self.task_errors >= MAX_ERRORS:
log.critical(f"[Guild {self.guild.id}] Reached 5 errors, closing the task...")
try:
await self.to_channel.send(
_(
":warning: **Attention**\nMultiple bugs occured within the loop task. "
"It is therefore stopped. The bot will stop refreshing informations "
"and launching matches.\nIf you believe the issue is fixed, resume "
"the tournament with `{prefix}tfix resumetask`, else contact bot "
"administrators."
).format(prefix=self.bot_prefix)
)
except Exception as e:
log.error(f"[Guild {self.guild.id}] Can't tell TOs of the above bug.", exc_info=e)
finally:
self.stop_loop_task()
return # shouldn't be reached but to make sure
try:
await self._update_participants_list()
await self._update_match_list()
self.update_streamer_list()
except Exception as e:
log.error(
f"[Guild {self.guild.id}] Can't update internal match and participant list! "
"This may be an error from the upstream bracket, or the bot failed when "
"checking for changes.",
exc_info=e,
)
self.task_errors += 1
return
coros = [
self.launch_sets(),
self.check_for_channel_timeout(),
self.check_for_too_long_matches(),
]
results = await asyncio.gather(*coros, return_exceptions=True)
for i, result in enumerate(results):
if result is None:
continue
log.warning(f"[Guild {self.guild.id}] Failed with coro {coros[i]}.", exc_info=result)
self.task_errors += 1
try:
await self.launch_streams()
except Exception as e:
log.error(f"[Guild {self.guild.id}] Can't update streams.", exc_info=e)
self.task_errors += 1
# saving is done after all of our jobs, so the data shouldn't move too much
await self.save()
@tasks.loop(seconds=15)
async def loop_task(self):
"""
A `discord.ext.tasks.Loop` object, started with the tournament's start and running each 15
seconds.
Does the required background stuff, such as updating the matches list, launch new matches,
update streamers, check for AFK...
Running this will acquire our `lock`.
See the documentation on a Loop object for more details.
.. warning:: Use `start_loop_task` for starting the task, not `Loop.start
<discord.ext.tasks.Loop.start>`.
Raises
------
asyncio.TimeoutError
Running the task took more than 30 seconds
"""
# we're using a lock to prevent actions such as score setting of DQs being done while we're
# updating the match list, which can make the bot think there were manual bracket changes
try:
async with self.lock:
# since this will block other commands, we put an uncatched timeout
await asyncio.wait_for(self._loop_task(), 30)
except Exception:
raise
else:
if self.task_errors:
# there were previous errors but the task ran without any new exception
# so we're resetting the errors count (or 502 errors will keep cancelling the task)
self.task_errors = 0
loop_task.__doc__ = loop_task.coro.__doc__
[docs] async def cancel_timeouts(self):
"""
Sometimes relaunching the bot after too long will result in a lot of DQs due to AFK checks,
so this function will cancel all AFK checks for the matches that are going to have
players DQed.
"""
if self.delay is None:
return
to_timeout = [
x
for x in self.matches
if x.status == "ongoing"
and x.checked_dq is False
and x.duration is not None
and x.duration > self.delay
and (x.player1.spoke is False or x.player2.spoke is False)
]
if not to_timeout:
return
log.warning(
f"[Guild {self.guild.id}] Cancelling DQ checks for {len(to_timeout)} "
"matches due to loop task being resumed."
)
for match in to_timeout:
match.checked_dq = True
await self.to_channel.send(
_(
":information_source: Task is being resumed after some time. To prevent "
"participants being incorrectly marked as AFK, {len} matches' AFK check are "
"disabled."
).format(len=len(to_timeout))
)
[docs] async def start_loop_task(self):
"""
Starts the internal loop task.
This will check for possible leftovers and cancel any previous task matching our name
within the current asyncio loop. We **do not** want duplicated tasks, as this will result
in the worst nightmare (RIP DashDances #36 and Super Smash Bronol #46).
Then we try to prevent abusive disqualifications with `cancel_timeouts`. Oh and we also
set a contextual locale for i18n, see
`redbot.core.i18n.set_contextual_locales_from_guild`.
Finally, the task is started and given the name "Tournament {tournamet_id}"
"""
# We had some issues with duplicated tasks, this isn't even supposed to be possible, but
# it somehow happened, and more than once. Having duplicated tasks is the worst scenario,
# all channels and messages are duplicated, and most commands won't work,
# ruining a tournament
#
# To prevent this from happening, we're giving a name to our task (based on ID) and
# check if there are tasks with the same name within the current asyncio loop
# If we find tasks with matching names, we cancel them
#
task_name = f"Tournament {self.id}"
old_tasks = [x for x in asyncio.all_tasks() if x.get_name() == task_name]
if old_tasks:
log.warning(f"[Guild {self.guild.id}] Found old tasks, cancelling")
for old_task in old_tasks:
if not old_task.done():
old_task.cancel()
try:
await self.cancel_timeouts()
except Exception as e:
log.error(
f"[Guild {self.guild.id}] Failed cancelling timeouts. "
"Loop task will still be resumed.",
exc_info=e,
)
await set_contextual_locales_from_guild(self.bot, self.guild)
self.task = self.loop_task.start()
self.task.set_name(task_name)
[docs] def stop_loop_task(self):
"""
Stops the loop task. This is preferred over `discord.ext.tasks.Loop.cancel`.
"""
if self.task and not self.task.done():
self.task.cancel()
# debug util
# def _debug_dump(self):
# file = open(cog_data_path(raw_name="Tournaments") / "debug.txt", "w+")
# s = self
# content = (
# f"----- TOURNAMENT {s.name} -----\n"
# f"url={s.url} id={s.id} status={s.status} phase={s.phase}\n"
# f"tstart={s.tournament_start}\nrstart={s.register_start} "
# f"rsecstart={s.register_second_start} rstop={s.register_stop}\n"
# f"cstart={s.checkin_start} cstop={s.checkin_stop}\n"
# f"next_event={s.next_scheduled_event()} ignored={s.ignored_events}\n"
# f"rphase={s.register_phase} participantslen={len(s.participants)}\n"
# f"cphase={s.register_phase} "
# f"checkedlen={len([x for x in s.participants if x.checked_in])}"
# )
# content += "\n\n\n"
# m: Match
# for i, m in enumerate(self.matches):
# if m.start_time and m.checked_dq is False:
# time_until_dq_check = (
# m.start_time + timedelta(minutes=self.delay)
# ) - datetime.now(self.tz)
# else:
# time_until_dq_check = None
# if m.end_time:
# time_until_delete = (m.end_time + timedelta(minutes=5)) - datetime.now(self.tz)
# else:
# time_until_delete = None
# content += (
# f"----- MATCH {i} -----\n"
# f"status={m.status} round={m.round} set={m.set} id={m.id}\n"
# f"start_time={m.start_time.strftime('%H:%M:%S') if m.start_time else None} "
# f"end_time={m.end_time.strftime('%H:%M:%S') if m.end_time else None} "
# f"underway={m.underway} channel={m.channel.id if m.channel else None}\n"
# f"checked_dq={m.checked_dq} time_until_dq_check={time_until_dq_check} "
# f"time_until_delete={time_until_delete}\n"
# f"player1= name={m.player1} player_id={m.player1.player_id} spoke="
# f"{m.player1.spoke} discord_id={m.player1.id}\n"
# f"player1= name={m.player2} player_id={m.player2.player_id} spoke="
# f"{m.player2.spoke} discord_id={m.player2.id}\n\n"
# )
# content += "\n\n\n"
# s: Streamer
# for i, s in enumerate(self.streamers):
# content += (
# f"----- STREAMER {i} -----\n"
# f"link={s.link} room_id={s.room_id} room_code={s.room_code}\n"
# f"member={s.member}\n"
# f"current_match={s.current_match}\n"
# f"matches={s.matches}\n\n"
# )
# content += "\n\n\n"
# p: Participant
# for i, p in enumerate(self.participants):
# content += (
# f"----- PARTICIPANT {i} -----\n"
# f"name={p} id={p.id} player_id={p.player_id}\n"
# f"match_set={p.match.set if p.match else None} match_id="
# f"{p.match.id if p.match else None} spoke={p.spoke} check={p.checked_in}\n\n"
# )
# file.write(content)
# file.close()
# async def debug_loop_task(self):
# while True:
# self._debug_dump()
# await asyncio.sleep(1)
# abstract methods that will have to be overwritten by the class that inherits from this
# represents the API calls done to the remote bracket
[docs] async def _get_all_rounds(self) -> List[int]:
"""
Return a list of all rounds in the bracket. This is used to determine the top 8.
This is a new method because our Match class will not be created without players, so
we add this new method which only fetch what we need.
Returns
-------
List[int]
The list of rounds.
"""
raise NotImplementedError
[docs] async def _update_participants_list(self):
"""
Updates the internal list of participants, checking for changes such as:
* Player DQ/removal
* New player added (pre game)
.. warning:: A name change on remote is considered as a player removal + addition. If the
name doesn't match any member, they will be rejected.
"""
raise NotImplementedError
[docs] async def _update_match_list(self):
"""
Updates the internal list of changes, checking for changes such as:
* Score set manually
* Score modified (if there are ongoing/finished sets beyond this match in the bracket,
they will be reset)
* Match reset (the set will be relaunched, ongoing/finished sets beyond this match in
the bracket will be reset)
"""
raise NotImplementedError
[docs] async def start(self):
"""
Starts the tournament.
Raises
------
RuntimeError
The tournament is already started.
"""
raise NotImplementedError
[docs] async def stop(self):
"""
Stops the tournament.
Raises
------
RuntimeError
The tournament is already stopped or not started.
"""
raise NotImplementedError
[docs] async def add_participant(self, name: str, seed: Optional[int] = None):
"""
Adds a participant to the tournament.
Parameters
----------
name: str
The name of the participant
seed: int
The participant's new seed. Must be between 1 and the current number of participants
(including the new record). Omit to place at the bottom.
"""
raise NotImplementedError
[docs] async def add_participants(
self, participants: Optional[List[Participant]] = None, force: bool = False
) -> int:
"""
Adds a list of participants to the tournament, ordered as you want them to be seeded.
The participants will have their `Participant.player_id` updated as needed.
Parameters
----------
participants: Optional[List[Participant]]
The list of participants. The first element will be seeded 1. If not provided, will
use the instance's `participants` attribute instead.
force: bool
If you want the bot to override the previous list of participants on the bracket.
Defaults to `False`.
* If set to `True`: All manually added participants and seeding will be lost, and
the new list will be exactly the same as what's provided. All player IDs will
be modified.
* If set to `False`: The bot will call `list_participants` and remove all elements
from the list where the player ID is already inside the upstream list. Then we
bulk add what's remaining, without clearing the rest.
Participants are still seeded, but at the end, separated from the rest.
Returns
-------
int
How many members were appended to the list. Can be useful for knowing if the bot
appended participants or if it was an initial upload (or forced).
Raise
-----
RuntimeError
The list of participants provided was empty, or there was nothing new to upload.
"""
raise NotImplementedError
[docs] async def destroy_player(self, player_id: str):
"""
Destroys a player. This is the same call as Player.destroy, but only the player ID is
needed. Useful for unavailable discord member.
Parameters
----------
player_id: str
The player to remove.
"""
raise NotImplementedError
[docs] async def list_participants(self) -> List[Participant]:
"""
Returns the list of participants from the tournament host.
Returns
-------
List[str]
The list of participants.
"""
raise NotImplementedError
[docs] async def list_matches(self) -> List[Match]:
"""
Returns the list of matches from the tournament host.
Returns
-------
List[str]
The list of matches.
"""
raise NotImplementedError
[docs] async def reset(self):
"""
Resets the bracket.
"""
raise NotImplementedError
[docs]class Streamer:
"""
Represents a streamer in the tournament. Will be assigned to matches. Does not necessarily
exists on remote depending on the provider.
There is no API call in this class *for now*.
Parameters
----------
tournament: Tournament
The current tournament
member: discord.Member
The streamer on Discord. Must be in the tournament's guild.
channel: str
The streamer's channel. Must only be the last part of the URL, not full. (e.g. for
https://twitch.tv/el_laggron use ``channel="el laggron"``)
Attributes
----------
tournament: Tournament
The current tournament
member: discord.Member
The streamer on Discord. Must be in the tournament's guild.
channel: str
The streamer's channel. Must only be the last part of the URL, not full. (e.g. for
https://twitch.tv/el_laggron use ``channel="el laggron"``)
link: str
The streamer's full channel URL
room_id: str
Streamer's room ID, specific to Smash Bros.
room_code: str
Streamer's room code, specific to Smash Bros.
matches: List[Union[Match, int]]
The list of matches in the streamer's queue. Can be `Match` if it exists (both players
available, on hold) or `int`, representing the set.
current_match: Optional[Match]
The streamer's current match.
"""
def __init__(
self,
tournament: Tournament,
member: discord.Member,
channel: str,
respect_order: bool = False,
):
self.tournament = tournament
self.member = member
self.channel = channel
self.respect_order = respect_order
self.link = f"https://www.twitch.tv/{channel}/"
self.room_id = None
self.room_code = None
self.matches: List[Union[Match, int]] = []
self.current_match: Optional[Match] = None
@classmethod
def from_saved_data(cls, tournament: Tournament, data: dict):
guild = tournament.guild
member = guild.get_member(data["member"])
if member is None:
raise RuntimeError("Streamer member lost.")
cls = cls(tournament, member, data["channel"], data["respect_order"])
cls.room_id = data["room_id"]
cls.room_code = data["room_code"]
cls.matches = list(
filter(
None, [tournament.find_match(match_set=str(x))[1] or x for x in data["matches"]]
)
)
for match in cls.matches:
if not isinstance(match, int):
match.streamer = cls
if data["current_match"]:
cls.current_match = tournament.find_match(match_set=str(data["current_match"]))[1]
return cls
def to_dict(self) -> dict:
return {
"member": self.member.id,
"channel": self.channel,
"respect_order": self.respect_order,
"room_id": self.room_id,
"room_code": self.room_code,
"matches": [self.get_set(x) for x in self.matches],
"current_match": self.get_set(self.current_match) if self.current_match else None,
}
[docs] def get_set(self, x):
"""
Return the set number of a match in the streamer's queue. Accepts `Match` or `int`.
Parameters
----------
x: Union[Match, int]
The match you need
"""
return int(x.set) if hasattr(x, "set") else x
def __str__(self):
return self.link
[docs] def set_room(self, room_id: str, code: Optional[str] = None):
"""
Set streamer's room info (specific to Smash Bros.)
Parameters
----------
room_id: str
Streamer's room ID
code: Optional[str]
Streamer's room code
"""
self.room_id = room_id
self.room_code = code
[docs] async def check_integrity(self, sets: int, *, add: bool = False):
"""
Verify if the list of sets provided is valid before adding them to the list.
Parameters
----------
sets: int
The list of sets you want to check (and add). Only `int`, no `Match` instance.
add: bool
If you want to add the valid sets to the queue at the same time.
Returns
-------
dict
A dictionnary of the errors that occured (set -> translated error msg). If this is
empty, it's all good.
"""
errors = {}
for _set in sets:
if _set in [self.get_set(x) for x in self.matches]:
errors[_set] = _("You already have that set in your queue.")
continue
for streamer in self.tournament.streamers:
if _set in [self.get_set(x) for x in streamer.matches]:
errors[_set] = _(
"That set already has a streamer defined *(<{streamer}>)*."
).format(streamer=streamer)
continue
match = self.tournament.find_match(match_set=str(_set))[1]
if match:
if match.status == "finished":
errors[_set] = _("That match is finished.")
continue
if add is False:
continue
match.streamer = self
if match.status == "ongoing":
# match is ongoing, we have to tell players
if not self.matches:
# first match in the list, no need to interrupt, just send info
match.on_hold = False
else:
# match has to be paused
match.on_hold = True
await match.stream_queue_add()
if add:
self.matches.append(match or _set)
return errors
[docs] async def add_matches(self, *sets):
"""
Add matches to the streamer's queue.
Parameters
----------
*sets: Union[Match, int]
The matches you want to add.
"""
self.matches.extend(*sets)
[docs] async def remove_matches(self, *sets: int):
"""
Remove a list of matches from the streamer's queue.
Parameters
----------
*sets: int
The list of sets you want to remove. Only `int`, no `Match` instance.
Raises
------
KeyError
The list was unchanged.
"""
new_list = []
to_remove = []
for match in self.matches:
if self.get_set(match) in sets:
to_remove.append(match)
else:
new_list.append(match)
if not to_remove:
raise KeyError("None of the given sets found.")
else:
self.matches = new_list
for match in to_remove:
if isinstance(match, Match):
await match.cancel_stream()
if match == self.current_match:
self.current_match = None
[docs] def swap_match(self, set1: int, set2: int):
"""
Swap the position of two matches in the streamer's queue.
Parameters
----------
set1: int
The first set.
set2: int
The second set.
Raises
------
KeyError
One or more sets not found
"""
try:
i1, match1 = next(
filter(lambda x: set1 == self.get_set(x[1]), enumerate(self.matches))
)
i2, match2 = next(
filter(lambda x: set2 == self.get_set(x[1]), enumerate(self.matches))
)
except StopIteration as e:
raise KeyError("One set not found.") from e
self.matches[i1] = match2
self.matches[i2] = match1
[docs] def insert_match(self, set: int, *, set2: int = None, position: int = None):
"""
Insert a match in the list. The match must already exist in the list.
Provide either ``set2`` or ``position`` as keyword argument.
Parameters
----------
set: int
The set you want to move. Only `int` type, not `Match`.
set2: int
The set you want to use to define the position. Only `int` type, not `Match`.
position: int
The new position in the list. 0 = first ; 1 = second ...
Providing a number out of bounds will move the item at the limit, it's just *Python's
magic*. (eg: -5 moves to first place)
Raises
------
KeyError
The given set was not found
RuntimeError
Neither ``set2`` or ``position`` was provided.
"""
if set2 is None and position is None:
raise RuntimeError("Provide set2 or position.")
try:
i, match = next(filter(lambda x: set == self.get_set(x[1]), enumerate(self.matches)))
if set2:
position = next(
filter(lambda x: set2 == self.get_set(x[1]), enumerate(self.matches))
)[0]
except StopIteration as e:
raise KeyError("One set not found.") from e
del self.matches[i]
self.matches.insert(position, match)
[docs] async def end(self):
"""
Cancels all streams for the streamer's queue, telling the players.
Basically calls `Match.cancel_stream` for existing matches.
"""
for match in filter(lambda x: isinstance(x, Match), self.matches):
await match.cancel_stream()
def _update_list(self):
matches = []
for match in self.matches:
if isinstance(match, Match):
matches.append(match)
continue
match_object = self.tournament.find_match(match_set=str(match))[1]
if match_object:
if match_object.status != "pending":
continue
match_object.streamer = self
match_object.on_hold = True
matches.append(match_object)
else:
matches.append(match)
self.matches = matches
if self.current_match:
if self.current_match.status != "finished":
return # wait for it to end
try:
self.matches.remove(self.current_match)
except ValueError:
pass
self.current_match = None
try:
next_match = self.matches[0]
except IndexError:
return
if isinstance(next_match, int):
return
self.current_match = next_match