-
Notifications
You must be signed in to change notification settings - Fork 1
feat: tournaments #94
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
"""Tournaments | ||
|
||
Revision ID: 370497031613 | ||
Revises: f1a5ab97b1db | ||
Create Date: 2020-11-02 09:32:20.371475 | ||
|
||
""" | ||
from alembic import op | ||
import sqlalchemy as sa | ||
|
||
|
||
# revision identifiers, used by Alembic. | ||
revision = '370497031613' | ||
down_revision = 'f1a5ab97b1db' | ||
branch_labels = None | ||
depends_on = None | ||
|
||
|
||
def upgrade(): | ||
# ### commands auto generated by Alembic - please adjust! ### | ||
op.create_table('tournaments', | ||
sa.Column('message_id', sa.BigInteger(), nullable=False), | ||
sa.Column('game_role', sa.BigInteger(), nullable=False), | ||
sa.Column('team_size', sa.Integer(), nullable=False), | ||
sa.Column('team_count', sa.Integer(), nullable=False), | ||
sa.Column('registration_expires', sa.DateTime(), nullable=False), | ||
sa.Column('voice_channel_id', sa.BigInteger(), nullable=False), | ||
sa.Column('text_channel_id', sa.BigInteger(), nullable=False), | ||
sa.Column('role_id', sa.BigInteger(), nullable=False), | ||
sa.PrimaryKeyConstraint('message_id') | ||
) | ||
op.create_table('tournament_teams', | ||
sa.Column('reaction', sa.Text(), nullable=False), | ||
sa.Column('tournament_message_id', sa.BigInteger(), nullable=False), | ||
sa.Column('voice_channel_id', sa.BigInteger(), nullable=False), | ||
sa.Column('text_channel_id', sa.BigInteger(), nullable=False), | ||
sa.Column('role_id', sa.BigInteger(), nullable=False), | ||
sa.ForeignKeyConstraint(['tournament_message_id'], ['tournaments.message_id'], ondelete='CASCADE'), | ||
sa.PrimaryKeyConstraint('reaction', 'tournament_message_id') | ||
) | ||
op.create_table('tournament_team_members', | ||
sa.Column('member_id', sa.BigInteger(), nullable=False), | ||
sa.Column('team_reaction', sa.Text(), nullable=False), | ||
sa.Column('tournament_message_id', sa.BigInteger(), nullable=False), | ||
sa.ForeignKeyConstraint(['team_reaction', 'tournament_message_id'], ['tournament_teams.reaction', 'tournament_teams.tournament_message_id'], ondelete='CASCADE'), | ||
sa.PrimaryKeyConstraint('member_id', 'team_reaction', 'tournament_message_id') | ||
) | ||
# ### end Alembic commands ### | ||
|
||
|
||
def downgrade(): | ||
# ### commands auto generated by Alembic - please adjust! ### | ||
op.drop_table('tournament_team_members') | ||
op.drop_table('tournament_teams') | ||
op.drop_table('tournaments') | ||
# ### end Alembic commands ### |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
from datetime import datetime, timedelta | ||
|
||
import discord | ||
from discord.ext import commands | ||
|
||
from db import db_session | ||
from extensions.util import remove_reaction, create_role_and_channels | ||
from models.tournament import Tournament | ||
|
||
|
||
class Tournaments(commands.Cog, name="Tournaments"): | ||
|
||
def __init__(self, bot): | ||
self.bot: discord.Client = bot | ||
|
||
@commands.command() | ||
async def tournament(self, ctx, role: discord.Role, team_size: int, team_count: int, period: int = 15): | ||
expires = datetime.now() + timedelta(minutes=period) | ||
embed = discord.Embed( | ||
color=discord.Color.blue(), | ||
title=f"A new {role.name} Tournament was started", | ||
) | ||
embed.add_field(name="Teams", | ||
value=f"This tournament will have {team_size} member(s) per team " | ||
f"and a maximum of {team_count} teams.") | ||
embed.add_field(name="Registration", | ||
value=f"If you want to enter a new team react with a new Reaction.\n" | ||
f"If you want to enter an existing team click in its Reaction.\n" | ||
f"To exit a team remove your Reaction.") | ||
embed.add_field(name="Deadline", | ||
value=f"Registration will be closed when {team_count} **full** teams are formed\n" | ||
f"OR\n" | ||
f"at {expires:%H:%M}.") | ||
msg = await ctx.send(embed=embed) | ||
role, voice, text = await create_role_and_channels(ctx.guild, f"{role.name} Tournament Participant", | ||
f" {role.name} Tournament") | ||
Tournament(message_id=msg.id, game_role_id=role.id, size=team_size, count=team_count, expires=expires, | ||
voice_id=voice.id, text_id=text.id, role_id=role.id) | ||
db_session.commit() | ||
|
||
@commands.Cog.listener() | ||
async def on_raw_reaction_add(self, event: discord.RawReactionActionEvent): | ||
tournament = Tournament.get(event.message_id) | ||
if tournament is None: | ||
return | ||
guild = self.guild(event.guild_id) | ||
if tournament.is_player_in_tournament(event.user_id): | ||
await remove_reaction(guild, event) | ||
return | ||
tournament_role = guild.get_role(tournament.role_id) | ||
reaction = event.emoji.name | ||
team = tournament.get_team(reaction) | ||
team_role: discord.Role = None | ||
if team is None: | ||
team_role, voice, text = await create_role_and_channels(guild, f"Team {reaction} Member", | ||
f"Team {reaction}") | ||
team = tournament.add_team(reaction=reaction, voice_id=voice.id, text_id=text.id, role_id=team_role.id) | ||
if team_role is None: | ||
team_role = guild.get_role(team.role_id) | ||
if len(team.members) >= tournament.team_size: | ||
await remove_reaction(guild, event) | ||
return | ||
await event.member.add_roles(tournament_role, team_role) | ||
team.add_member(event.user_id) | ||
db_session.commit() | ||
|
||
@commands.Cog.listener() | ||
async def on_raw_reaction_remove(self, event: discord.RawReactionActionEvent): | ||
tournament = Tournament.get(event.message_id) | ||
if tournament is None: | ||
return | ||
team = tournament.get_team(event.emoji.name) | ||
if team is None: | ||
return | ||
if not team.has_member(event.user_id): | ||
return | ||
guild = self.guild(event.guild_id) | ||
member: discord.Member = guild.get_member(event.user_id) | ||
tournament_role: discord.Role = guild.get_role(tournament.role_id) | ||
team_role = guild.get_role(team.role_id) | ||
await member.remove_roles(tournament_role, team_role) | ||
team.remove_member(event.user_id) | ||
if len(team.members) == 0: | ||
await guild.get_channel(team.voice_channel_id).delete() | ||
await guild.get_channel(team.text_channel_id).delete() | ||
await team_role.delete() | ||
tournament.remove_team(team.reaction) | ||
db_session.commit() | ||
|
||
@commands.Cog.listener() | ||
async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent): | ||
tournament = Tournament.get(event.message_id) | ||
if tournament is None: | ||
return | ||
if len(event.data["embeds"]) > 0: | ||
return | ||
guild = self.guild(int(event.data["guild_id"])) | ||
await guild.get_channel(tournament.voice_channel_id).delete() | ||
await guild.get_channel(tournament.text_channel_id).delete() | ||
await guild.get_role(tournament.role_id).delete() | ||
for team in tournament.teams: | ||
await guild.get_channel(team.voice_channel_id).delete() | ||
await guild.get_channel(team.text_channel_id).delete() | ||
await guild.get_role(team.role_id).delete() | ||
Tournament.delete(event.message_id) | ||
db_session.commit() | ||
|
||
def guild(self, guild_id: int) -> discord.Guild: | ||
return self.bot.get_guild(guild_id) | ||
|
||
|
||
def setup(bot): | ||
bot.add_cog(Tournaments(bot)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import discord | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we can create a sub dir for utils, because I have planned such a structure for the rework as well. So we can differentiate the utils for different extensions There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't the point of a util package to include common utility functions? If they are only useful for a single extension, then there would be no need for a separate file. I might be missing the point of your suggestion :D There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. At this point I only those functions useful for the tournament function. But we can keep it like this and I but my helper functions in here too. My thought in the first place was that we might have overhead because of imports for functions we do not need e.g when an extension is not even loaded There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with @KnutZuidema there. It makes more sense to have a utility file for including common utility functions. |
||
|
||
|
||
async def remove_reaction(guild: discord.Guild, payload: discord.RawReactionActionEvent): | ||
channel: discord.TextChannel = guild.get_channel(payload.channel_id) | ||
msg = await channel.fetch_message(payload.message_id) | ||
await msg.remove_reaction(payload.emoji, payload.member) | ||
return | ||
|
||
|
||
async def create_role_and_channels(guild: discord.Guild, role_name: str, channel_name: str) -> \ | ||
(discord.Role, discord.VoiceChannel, discord.TextChannel): | ||
role = await guild.create_role(name=role_name) | ||
overwrites = { | ||
role: discord.PermissionOverwrite(view_channel=True, read_messages=True, connect=True), | ||
guild.default_role: discord.PermissionOverwrite(view_channel=False, read_messages=False, connect=False) | ||
} | ||
voice = await guild.create_voice_channel(name=channel_name, overwrites=overwrites) | ||
text = await guild.create_text_channel(name=channel_name, overwrites=overwrites) | ||
TitusKirch marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return role, voice, text |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
from datetime import datetime | ||
|
||
from sqlalchemy import Column, Integer, BigInteger, DateTime, ForeignKey, Text, PrimaryKeyConstraint, \ | ||
ForeignKeyConstraint | ||
from sqlalchemy.orm import relationship | ||
|
||
from db import db_session | ||
from models.base import Base | ||
|
||
|
||
class Tournament(Base): | ||
__tablename__ = "tournaments" | ||
|
||
message_id = Column(BigInteger, primary_key=True) | ||
game_role = Column(BigInteger, nullable=False) | ||
team_size = Column(Integer, nullable=False) | ||
team_count = Column(Integer, nullable=False) | ||
registration_expires = Column(DateTime, nullable=False) | ||
voice_channel_id = Column(BigInteger, nullable=False) | ||
text_channel_id = Column(BigInteger, nullable=False) | ||
role_id = Column(BigInteger, nullable=False) | ||
|
||
teams = relationship("TournamentTeam", back_populates="tournament") | ||
|
||
def __init__(self, message_id: int, game_role_id: int, size: int, count: int, expires: datetime, | ||
voice_id: int, text_id: int, role_id: int): | ||
self.message_id = message_id | ||
self.game_role = game_role_id | ||
self.team_size = size | ||
self.team_count = count | ||
self.registration_expires = expires | ||
self.voice_channel_id = voice_id | ||
self.text_channel_id = text_id | ||
self.role_id = role_id | ||
db_session.add(self) | ||
|
||
@classmethod | ||
def get(cls, message_id: int) -> "Tournament": | ||
return db_session.query(Tournament).filter(Tournament.message_id == message_id).first() | ||
|
||
@classmethod | ||
def delete(cls, message_id: int): | ||
db_session.query(Tournament).filter(Tournament.message_id == message_id).delete() | ||
|
||
def get_team(self, reaction: str) -> "TournamentTeam": | ||
return db_session.query(TournamentTeam) \ | ||
.filter(TournamentTeam.tournament_message_id == self.message_id) \ | ||
.filter(TournamentTeam.reaction == reaction) \ | ||
.first() | ||
|
||
def add_team(self, reaction: str, voice_id: int, text_id: int, role_id: int) -> "TournamentTeam": | ||
return TournamentTeam(reaction, self.message_id, voice_id, text_id, role_id) | ||
|
||
def remove_team(self, reaction: str): | ||
db_session.query(TournamentTeam) \ | ||
.filter(TournamentTeam.reaction == reaction and TournamentTeam.tournament_message_id == self.message_id) \ | ||
.delete() | ||
|
||
def is_player_in_tournament(self, member_id: int) -> bool: | ||
member = db_session.query(TournamentTeamMember) \ | ||
.join(TournamentTeam, TournamentTeamMember.team_reaction == TournamentTeam.reaction) \ | ||
.join(Tournament, TournamentTeam.tournament_message_id == Tournament.message_id) \ | ||
.filter(TournamentTeamMember.member_id == member_id) \ | ||
.filter(Tournament.message_id == self.message_id).first() | ||
return member is not None | ||
|
||
|
||
class TournamentTeam(Base): | ||
__tablename__ = "tournament_teams" | ||
|
||
reaction = Column(Text, nullable=False) | ||
tournament_message_id = Column(BigInteger, ForeignKey("tournaments.message_id", ondelete="CASCADE")) | ||
voice_channel_id = Column(BigInteger, nullable=False) | ||
text_channel_id = Column(BigInteger, nullable=False) | ||
role_id = Column(BigInteger, nullable=False) | ||
|
||
PrimaryKeyConstraint(reaction, tournament_message_id) | ||
|
||
members = relationship("TournamentTeamMember", back_populates="team") | ||
tournament = relationship("Tournament", back_populates="teams") | ||
|
||
def __init__(self, reaction: str, tournament_id: int, voice_id: int, text_id: int, role_id: int): | ||
self.tournament_message_id = tournament_id | ||
self.voice_channel_id = voice_id | ||
self.text_channel_id = text_id | ||
self.reaction = reaction | ||
self.role_id = role_id | ||
db_session.add(self) | ||
|
||
def add_member(self, member_id: int) -> "TournamentTeamMember": | ||
return TournamentTeamMember(member_id, self.reaction, self.tournament_message_id) | ||
|
||
def remove_member(self, member_id): | ||
db_session.query(TournamentTeamMember) \ | ||
.filter(TournamentTeamMember.member_id == member_id) \ | ||
.filter(TournamentTeamMember.team_reaction == self.reaction) \ | ||
.delete() | ||
|
||
def has_member(self, member_id) -> bool: | ||
member = db_session.query(TournamentTeamMember) \ | ||
.filter(TournamentTeamMember.member_id == member_id) \ | ||
.filter(TournamentTeamMember.team_reaction == self.reaction) \ | ||
.first() | ||
return member is not None | ||
|
||
|
||
class TournamentTeamMember(Base): | ||
__tablename__ = "tournament_team_members" | ||
|
||
member_id = Column(BigInteger, nullable=False) | ||
team_reaction = Column(Text, nullable=False) | ||
tournament_message_id = Column(BigInteger, nullable=False) | ||
|
||
PrimaryKeyConstraint(member_id, team_reaction, tournament_message_id) | ||
ForeignKeyConstraint((team_reaction, tournament_message_id), | ||
("tournament_teams.reaction", "tournament_teams.tournament_message_id"), | ||
ondelete="CASCADE") | ||
|
||
team = relationship("TournamentTeam", back_populates="members") | ||
|
||
def __init__(self, member_id: int, team_reaction: str, tournament_message_id: int): | ||
self.member_id = member_id | ||
self.team_reaction = team_reaction | ||
self.tournament_message_id = tournament_message_id | ||
db_session.add(self) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we check if {team_count} is an even number or better if it fits the {team_count} structure?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some validation should be added, for sure. Currently you could simply put
team_size
at 0 and nobody can create a team 😂An even number would be easier if all tournaments will only consist of 1v1 matches, however I think it would be interesting to also support automatic group creation with a round-robin match schedule or something similar.
However, for a first test it might be easier to force an even number.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good to me