From fe8fff3c0579d30f9e5c7276775d1098935be4e0 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Sun, 2 Jun 2024 11:15:22 +1000 Subject: [PATCH 01/26] =?UTF-8?q?=F0=9F=94=A7=20Update=20Kube=20config=20f?= =?UTF-8?q?or=20SixMan=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding and updating necessary config for SixMan deployment. --- .env.local | 4 + .github/workflows/deploy.yaml | 6 +- README.md | 2 + db/.env.template | 4 + db/data/init.sql | 59 +++++ docker-compose.yaml | 32 +++ infra/values.yaml | 32 ++- requirements.txt | Bin 540 -> 602 bytes src/lib/bot/__init__.py | 14 +- src/lib/cogs/Cogs.py | 5 +- src/lib/cogs/SixMans.py | 467 ++++++++++++++++++++++++++++++++++ src/lib/db/__init__.py | 46 ++++ src/utils/SixMans.py | 64 +++++ 13 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 db/.env.template create mode 100644 db/data/init.sql create mode 100644 src/lib/cogs/SixMans.py create mode 100644 src/lib/db/__init__.py create mode 100644 src/utils/SixMans.py diff --git a/.env.local b/.env.local index 98b0ba5..de6af98 100644 --- a/.env.local +++ b/.env.local @@ -1,2 +1,6 @@ BOT_TOKEN=.local-secrets/bot-token CONFIG_FILE=config.yaml.local +DB_DATABASE=SCUFFBOT +DB_HOST=db +DB_PASSWORD=.local-secrets/db-password +DB_USER=SCUFFBOT diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 0d174e2..33ae838 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -69,8 +69,6 @@ jobs: steps: - name: Checkout infrastructure config uses: actions/checkout@v4 - with: - sparse-checkout: infra - name: Login to Docker Hub uses: docker/login-action@v3 @@ -96,11 +94,13 @@ jobs: ENVIRONMENT: ${{ needs.build.outputs.environment }} REPO_NAME: ${{ needs.build.outputs.repo-name }} COMMIT_SHA: ${{ needs.build.outputs.commit-sha }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_ROOT_PASSWORD: ${{ secrets.DB_ROOT_PASSWORD }} DOCKERHUB_USERNAME: ${{ secrets.ORG_DOCKERHUB_USERNAME }} DOCKERHUB_REPO: ${{ secrets.ORG_DOCKERHUB_REPO }} BOT_TOKEN: ${{ needs.build.outputs.environment == 'staging' && secrets.BOT_TOKEN_STAGING || secrets.BOT_TOKEN_PROD }} run: | cat infra/values.yaml | envsubst | \ helm upgrade --install "$REPO_NAME" chartmuseum/discord-bot --version 0.1.0 \ - -f - --set-file scuffbotConfig=config.yaml \ + -f - --set-file scuffbotConfig=config.yaml --set-file sqlInitFile=db/data/init.sql \ --namespace="$REPO_NAME-$ENVIRONMENT" --create-namespace diff --git a/README.md b/README.md index 22756d9..b50d506 100644 --- a/README.md +++ b/README.md @@ -12,5 +12,7 @@ If you would like to run locally: | Filename | Description | |--------------------|------------------------------------| | `bot-token` | Token for Discord bot | + | `db-password` | Default password for database | + | `db-root-password` | Root password for the database | 4. Copy [/db/.env.template](./db/.env.template) to `db/.env`, and adjust values as necessary. 5. Run the bot with `docker compose up -d --build`. diff --git a/db/.env.template b/db/.env.template new file mode 100644 index 0000000..3561728 --- /dev/null +++ b/db/.env.template @@ -0,0 +1,4 @@ +MYSQL_DATABASE=SCUFFBOT +MYSQL_USER=SCUFFBOT +MYSQL_PASSWORD=aStrongPassword +MYSQL_ROOT_PASSWORD=anotherStrongPassword diff --git a/db/data/init.sql b/db/data/init.sql new file mode 100644 index 0000000..fe11b6d --- /dev/null +++ b/db/data/init.sql @@ -0,0 +1,59 @@ +CREATE TABLE + `SixManParty` ( + `PartyID` INTEGER NOT NULL AUTO_INCREMENT, + `LobbyID` INTEGER DEFAULT NULL, + `GameID` INTEGER DEFAULT NULL, + PRIMARY KEY (`PartyID`) + ); + +CREATE TABLE + `SixManLobby` ( + `LobbyID` INTEGER NOT NULL, + `MessageID` TEXT NOT NULL, + `VoiceChannelID` TEXT NOT NULL, + `TextChannelID` TEXT NOT NULL, + `RoleID` TEXT NOT NULL, + `VoiceChannelA` VARCHAR(255) DEFAULT NULL UNIQUE, + `VoiceChannelB` VARCHAR(255) DEFAULT NULL UNIQUE, + PRIMARY KEY (`LobbyID`) + ); + +CREATE TABLE + `SixManGames` ( + `GameID` INTEGER NOT NULL AUTO_INCREMENT, + `Timestamp` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `1v1_A` INTEGER DEFAULT NULL, + `1v1_B` INTEGER DEFAULT NULL, + `2v2_A` INTEGER DEFAULT NULL, + `2v2_B` INTEGER DEFAULT NULL, + `3v3_A` INTEGER DEFAULT NULL, + `3v3_B` INTEGER DEFAULT NULL, + PRIMARY KEY (`GameID`) + ); + +CREATE TABLE + `SixManUsers` ( + `PartyID` INTEGER NOT NULL, + `UserID` VARCHAR(255) NOT NULL, + `Type` INTEGER NOT NULL DEFAULT 0, + `Team` INTEGER NOT NULL DEFAULT 0, + `isOnesPlayer` INTEGER DEFAULT NULL, + PRIMARY KEY (`PartyID`, `UserID`) + ); + +ALTER TABLE `SixManParty` ADD CONSTRAINT `FK_LobbyID_SixManParty` FOREIGN KEY (`LobbyID`) REFERENCES `SixManLobby` (`LobbyID`) ON DELETE SET NULL, +ADD CONSTRAINT `FK_GameID` FOREIGN KEY (`GameID`) REFERENCES `SixManGames` (`GameID`) ON DELETE SET NULL; + +ALTER TABLE `SixManUsers` ADD CONSTRAINT `FK_PartyID_SixManUsers` FOREIGN KEY (`PartyID`) REFERENCES `SixManParty` (`PartyID`) ON DELETE CASCADE; + +DELIMITER $$ + +CREATE TRIGGER DeletePartyCascade +AFTER DELETE ON SixManParty +FOR EACH ROW +BEGIN + DELETE FROM SixManLobby WHERE LobbyID = OLD.LobbyID; + DELETE FROM SixManGames WHERE GameID = OLD.GameID; +END $$ + +DELIMITER ; diff --git a/docker-compose.yaml b/docker-compose.yaml index 7b9a0b9..23495c8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,5 +5,37 @@ services: restart: unless-stopped volumes: - ./:/app + depends_on: + db: + condition: service_healthy env_file: - ./.env.local + networks: + - backend + # MySQL Server + db: + image: "mysql/mysql-server:8.0" + restart: unless-stopped + env_file: + - ./db/.env + volumes: + - db_data:/var/lib/mysql + - ./db/data:/docker-entrypoint-initdb.d/ + networks: + - backend + # phpmyadmin + phpmyadmin: + depends_on: + - db + image: phpmyadmin + restart: always + ports: + - "8080:80" + environment: + PMA_HOST: db + networks: + - backend +volumes: + db_data: +networks: + backend: diff --git a/infra/values.yaml b/infra/values.yaml index 8da9f67..0a3101c 100644 --- a/infra/values.yaml +++ b/infra/values.yaml @@ -1,9 +1,7 @@ # TODO: Replace schema link below to permanent location # yaml-language-server: $schema=../../infra-helm-charts/charts/discord-bot/values.schema.json environment: "${ENVIRONMENT}" - version: "${COMMIT_SHA}" - deployments: - name: "scuffbot" image: "${DOCKERHUB_USERNAME}/${DOCKERHUB_REPO}:${REPO_NAME}-${ENVIRONMENT}" @@ -12,5 +10,35 @@ deployments: environment: BOT_TOKEN: /secrets/bot-token CONFIG_FILE: /config/config.yaml + DB_DATABASE: SCUFFBOT + DB_HOST: scuffbot-db + DB_PASSWORD: /secrets/db-password + DB_USER: SCUFFBOT secrets: bot-token: "${BOT_TOKEN}" + db-password: "${DB_PASSWORD}" + - name: "scuffbot-db" + image: "mysql/mysql-server:8.0" + config: + fileName: "init.sql" + fileVariable: sqlInitFile + mountPath: /docker-entrypoint-initdb.d/ + containerPort: 3306 + environment: + MYSQL_DATABASE: SCUFFBOT + MYSQL_USER: SCUFFBOT + MYSQL_PASSWORD: /secrets/db-password + MYSQL_ROOT_PASSWORD: /secrets/db-root-password + readinessProbe: + tcpSocket: + port: 3306 + secrets: + db-password: "${DB_PASSWORD}" + db-root-password: "${DB_ROOT_PASSWORD}" + service: + type: ClusterIP + port: 3306 + volumes: + - name: "scuffbot-db" + mountPath: "/var/lib/mysql" + size: "1Gi" diff --git a/requirements.txt b/requirements.txt index 47d06e429fcf5302d4c775597085d93af8a4f194..90ff5067f225028fd93d2a230eac6de98f658c75 100644 GIT binary patch delta 69 zcmbQka*Jg{38Q5$LnT8oLm@*BgDyidLq0 NV=x3_gUR`fYXLa*4*CE9 delta 11 Scmcb`GKXbD3FG8Vj4J>e#src8 diff --git a/src/lib/bot/__init__.py b/src/lib/bot/__init__.py index 017b4f1..8fef474 100644 --- a/src/lib/bot/__init__.py +++ b/src/lib/bot/__init__.py @@ -6,7 +6,9 @@ import logging import discord import yaml +import sys import os +from ..db import DB as SCUFF_DB env_file = find_dotenv(".env.local") load_dotenv(env_file) @@ -14,6 +16,8 @@ with open(os.environ["CONFIG_FILE"], "r", encoding="utf-8") as f: config = yaml.safe_load(f) +DB = SCUFF_DB() + class SCUFFBOT(commands.Bot): def __init__(self, is_dev): @@ -23,8 +27,14 @@ def __init__(self, is_dev): async def setup_hook(self): self.setup_logger() - await self.load_cog_manager() - + self.DB = DB + try: + self.DB.connect() + except Exception as e: + self.logger.error(f"Failed to connect to the database: {e}") + sys.exit(1) + await self.load_cog_manager() + def setup_logger(self): with open("./logging.json") as f: logging.config.dictConfig(json.loads(f.read())) diff --git a/src/lib/cogs/Cogs.py b/src/lib/cogs/Cogs.py index 7d04a92..3428f9b 100644 --- a/src/lib/cogs/Cogs.py +++ b/src/lib/cogs/Cogs.py @@ -8,6 +8,7 @@ import os import traceback + class Cogs(commands.Cog): def __init__(self, bot): @@ -71,8 +72,8 @@ async def load_cogs(self): async def cog_load(self): self.logger.info(f"[COG] Loaded {self.__class__.__name__}") - CogGroup = app_commands.Group( - name="cog", description="Manages SCUFFBOT cogs.", guild_ids=[1165195575013163038, 422983658257907732]) + CogGroup = app_commands.Group(name="cog", description="Manages SCUFFBOT cogs.", guild_ids=[ + 1165195575013163038, 422983658257907732]) @CogGroup.command(name="list", description="Lists all cog statuses.") @app_commands.checks.has_permissions(manage_guild=True) diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py new file mode 100644 index 0000000..c3fd835 --- /dev/null +++ b/src/lib/cogs/SixMans.py @@ -0,0 +1,467 @@ +from discord.ext import commands +from discord.ui import Button, View, Select, Modal, TextInput +from discord import app_commands, PermissionOverwrite, Object +from src.lib.bot import config, DB +from typing import Any, Callable, Union +import discord +import logging + +from src.utils.SixMans import PARTY_SIZE, SixMansState, SixMansMatchType, SixMansParty + +class SixMansPrompt(View): + def __init__(self, bot: discord.Client, party_id: int): + super().__init__(timeout=None) + self.bot = bot + self.message: Union[None | discord.Message] = None + + self.state = SixMansState.CHOOSE_CAPTAIN_ONE + self.game = SixMansMatchType.PRE_MATCH + + self.party = SixMansParty(bot, party_id) + + async def interaction_check(self, interaction: discord.Interaction): + if "custom_id" in interaction.data and interaction.data["custom_id"] == "team_comp": return True + match self.state: + case SixMansState.CHOOSE_CAPTAIN_ONE: + if interaction.user and interaction.user.id != self.party.captain_one.id: + await interaction.response.send_message(f"Only {self.party.captain_one.mention} can do this.", ephemeral=True) + return interaction.user and interaction.user.id == self.party.captain_one.id + case SixMansState.CHOOSE_CAPTAIN_TWO: + if interaction.user and interaction.user.id != self.party.captain_two.id: + await interaction.response.send_message(f"Only {self.party.captain_two.mention} can do this.", ephemeral=True) + return interaction.user and interaction.user.id == self.party.captain_two.id + case SixMansState.CHOOSE_1S_PLAYER | SixMansState.PLAYING | SixMansState.SCORE_UPLOAD | SixMansState.POST_MATCH: + if interaction.user and interaction.user.id not in [self.party.captain_one.id, self.party.captain_two.id]: + await interaction.response.send_message(f"Only {self.party.captain_one.mention} or {self.party.captain_two.mention} can do this.", ephemeral=True) + return interaction.user and interaction.user.id in [self.party.captain_one.id, self.party.captain_two.id] + + async def delete_lobby(self): + lobby_details = await self.party.get_details() + lobby_name = f"SixMans #{self.party.lobby_id}" + + guild = self.bot.get_channel(int(lobby_details["VoiceChannelID"])).guild + await (guild.get_role(int(lobby_details["RoleID"]))).delete(reason=f"{lobby_name} finished/cancelled") + await (self.bot.get_channel(int(lobby_details["VoiceChannelID"]))).delete(reason=f"{lobby_name} finished/cancelled") + await (self.bot.get_channel(int(lobby_details["TextChannelID"]))).delete(reason=f"{lobby_name} finished/cancelled") + + if lobby_details["VoiceChannelA"]: + await (self.bot.get_channel(int(lobby_details["VoiceChannelA"]))).delete(reason=f"{lobby_name} finished/cancelled") + if lobby_details["VoiceChannelB"]: + await (self.bot.get_channel(int(lobby_details["VoiceChannelB"]))).delete(reason=f"{lobby_name} finished/cancelled") + DB.execute("DELETE FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id) + + def get_match_type(self): + match self.game: + case SixMansMatchType.ONE_V_ONE: + match_type = "1v1" + case SixMansMatchType.TWO_V_TWO: + match_type = "2v2" + case SixMansMatchType.THREE_V_THREE: + match_type = "3v3" + return match_type + + async def create_break_out_rooms(self): + lobby_a = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_one.display_name}" + lobby_b = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_two.display_name}" + + team_one_players = self.party.get_players(1) + team_two_players = self.party.get_players(2) + + guild: discord.Guild = self.message.guild + voice_channel_a_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True,connect=True,view_channel=True)), team_one_players))) | {guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + voice_channel_b_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True,connect=True,view_channel=True)), team_two_players))) | {guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + + voice_channel_a = await guild.create_voice_channel(name=lobby_a, overwrites=voice_channel_a_perms, category=self.message.channel.category, reason=f"{lobby_a} created") + voice_channel_b = await guild.create_voice_channel(name=lobby_b, overwrites=voice_channel_b_perms, category=self.message.channel.category, reason=f"{lobby_b} created") + + DB.execute("UPDATE SixManLobby SET VoiceChannelA = %s, VoiceChannelB = %s WHERE LobbyID = %s", voice_channel_a.id, voice_channel_b.id, self.party.lobby_id) + + # Move members + for member in guild.get_channel(int(DB.field("SELECT VoiceChannelID FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id))).members: + if member in team_one_players: + await member.move_to(voice_channel_a) + else: + await member.move_to(voice_channel_b) + + def generate_flag_str(self, member: discord.Member): + data = DB.row("SELECT Type, isOnesPlayer FROM SixManUsers WHERE PartyID = %s AND UserID = %s", self.party.party_id, member.id) + flags = [] + match data["Type"]: + case 1 | 2: + flags.append("CAPTAIN") + match data["isOnesPlayer"]: + case 1: + flags.append("1s") + case 0: + flags.append("2s") + case _: pass + flags.append("3s") + return ", ".join(flags) + + def generate_match_summary(self) -> str: + scores = [0 if x is None else x for x in DB.row("SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.party.game_id).values()] + team_a = ['W' if x else '-' for x in [scores[i] > scores[i + 1] for i in range(0, len(scores), 2)]] + team_b = ['W' if x else '-' for x in [scores[i+1] > scores[i] for i in range(0, len(scores), 2)]] + + return (f"""``` +| | 1v1 | 2v2 | 3v3 | +|--------|-----|-----|-----| +| Team A | {team_a[0]} | {team_a[1]} | {team_a[2]} | +| Team B | {team_b[0]} | {team_b[1]} | {team_b[2]} |```""") + + def generate_match_composition(self) -> str: + players = DB.rows("SELECT UserID, Type, Team, isOnesPlayer FROM SixManUsers WHERE PartyID = %s", self.party.party_id) + + ones_a = [row["UserID"] for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 1] + ones_b = [row["UserID"] for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 2] + + twos_a = [row["UserID"] for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 1] + twos_b = [row["UserID"] for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 2] + + threes_a = [row["UserID"] for row in players if row["Team"] == 1] + threes_b = [row["UserID"] for row in players if row["Team"] == 2] + + return (f"""### 1v1 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.ONE_V_ONE else ''} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_a]) if ones_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_b]) if ones_b else 'TBD'} +### 2v2 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.TWO_V_TWO else ''} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_a]) if twos_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_b]) if twos_b else 'TBD'} +### 3v3 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.THREE_V_THREE else ''} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_a]) if threes_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_b]) if threes_b else 'TBD'} +""") + + def generate_embed(self): + team_one_players = self.party.get_players(1) + team_two_players = self.party.get_players(2) + team_one_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] + team_two_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] + match self.state: + case SixMansState.CHOOSE_CAPTAIN_ONE: + description = f"Hello! Welcome to {self.bot.user.mention} Six Mans!\n\nTo begin, {self.party.captain_one.mention} needs to select **ONE** player to join their team.\n\n" + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field(name="Team A", value="\n".join(team_one_str)) + embed.add_field(name="Team B", value="\n".join(team_two_str)) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed + case SixMansState.CHOOSE_CAPTAIN_TWO: + description = f"{self.party.captain_two.mention} now needs to select **TWO** players to join their team.\n\n" + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field(name="Team A", value="\n".join(team_one_str)) + embed.add_field(name="Team B", value="\n".join(team_two_str)) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed + case SixMansState.CHOOSE_1S_PLAYER: + ones_players = (DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 1", self.party.party_id), DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 2", self.party.party_id)) + if ones_players[0] == None and ones_players[1] == None: + waiting_message = "both team captains to nominate a player.**" + elif ones_players[0] == None and ones_players[1] != None: + waiting_message = f"{self.party.captain_one.mention} to nominate a player.**" + else: + waiting_message = f"{self.party.captain_two.mention} to nominate a player.**" + description = f"Perfect! We now have our teams sorted. We will start off with the 1v1 match.\n\nTo begin, each team captain needs to nominate a player from their own team to play their 1s match. You can do this by clicking the **Nominate player** button below.\n\n**Currently waiting on {waiting_message}" + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field(name="Team A", value="\n".join(team_one_str)) + embed.add_field(name="Team B", value="\n".join(team_two_str)) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed + case SixMansState.PLAYING: + match self.game: + case SixMansMatchType.PRE_MATCH: + description = f"Now that we have our 1s players sorted, we are ready to get the ball rolling... *pun intended :D*\n\nAmongst yourselves, please nominate a player to host a private match. Whether you create separate 1v1, 2v2, and 3v3 matches or create a single 3v3 match and re-use it for all matches is entirely up to you.\n\nFrom this point onwards, if you would like to see the entire team composition, click the **View team composition** button below.\n\nThe next screen will show you a breakdown of the matches with specific team compositions for each match.\n\nWhen you are ready to move on, click the **Break out** button below and you will be moved automatically into separate channels. May the best team win!" + case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: + match_type = self.get_match_type() + description = f"You are now playing the {match_type} match. **Don't forget to come back here before your next game starts.**\n\n{self.generate_match_summary()}\n{self.generate_match_composition()}\n\nOnce you have finished your {match_type} match, click on the **Finish {match_type}** button below. Best of luck!" + case SixMansState.SCORE_UPLOAD: + match_type = self.get_match_type() + + team_a_reported_score = self.party.reported_scores[self.party.captain_one.id][match_type] + team_b_reported_score = self.party.reported_scores[self.party.captain_two.id][match_type] + + if team_a_reported_score == (None, None) and team_b_reported_score == (None, None): + waiting_message = "both team captains to upload the match score.**" + elif team_a_reported_score == (None, None) and team_b_reported_score != (None, None): + waiting_message = f"{self.party.captain_one.mention} to upload the match score.**" + elif team_a_reported_score != (None, None) and team_b_reported_score == (None, None): + waiting_message = f"{self.party.captain_two.mention} to upload the match score.**" + else: + waiting_message = f"a resolution to a discrepancy between reported match scores.**" + + description = f"Now that the {match_type} match is complete, both team captains must upload the score of the match.\n\nThe score will not be registered if there is a score discrepancy between both teams.\n\n**Currently waiting on {waiting_message}" + case SixMansState.POST_MATCH: + match self.party.calculate_winner(): + case 1: + winner = "A" + case 2: + winner = "B" + description = f"### Congratulations Team {winner}!\nYou have won the six mans scrims!\n\nPress the **End Game** button below when you are done. Thanks for playing!" + case _: + description = "Uh oh! Something has gone wrong." + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed + + def add_button(self, label: str, style: discord.ButtonStyle, callback: Callable[[discord.Interaction], Any], **kwargs): + button = Button(label=label, style=style, row=1, **kwargs) + button.callback = callback + self.add_item(button) + + def update_options(self): + self.clear_items() + match self.state: + case SixMansState.CHOOSE_CAPTAIN_ONE | SixMansState.CHOOSE_CAPTAIN_TWO: + self.add_button("Choose Player(s)", discord.ButtonStyle.blurple, self.choose_button_callback) + self.add_button("Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) + case SixMansState.CHOOSE_1S_PLAYER: + self.add_button("Nominate Player", discord.ButtonStyle.blurple, self.choose_button_callback) + self.add_button("Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) + case SixMansState.PLAYING: + match self.game: + case SixMansMatchType.PRE_MATCH: + self.add_button("Break Out", discord.ButtonStyle.blurple, self.break_out_button_callback) + self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: + match_label = self.get_match_type() + self.add_button(f"Finish {match_label}", discord.ButtonStyle.blurple, self.finish_button_callback) + self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + self.add_button(f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) + case SixMansState.SCORE_UPLOAD: + self.add_button("Upload Scores", discord.ButtonStyle.blurple, self.upload_scores_callback) + self.add_button("View team composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + case SixMansState.POST_MATCH: + self.add_button("End Game", discord.ButtonStyle.red, self.cancel_button_callback) + + async def update_view(self, embed=None): + self.update_options() + if self.message: + await self.message.edit(embed=embed or self.generate_embed(), view=self) + + async def cancel_button_callback(self, _: discord.Interaction): + await self.delete_lobby() + + async def choose_button_callback(self, interaction: discord.Interaction): + match self.state: + case SixMansState.CHOOSE_CAPTAIN_ONE | SixMansState.CHOOSE_CAPTAIN_TWO: + users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.party.party_id)] + reason = "join your team." + case SixMansState.CHOOSE_1S_PLAYER: + users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party.party_id, DB.field("SELECT Team FROM SixManUsers WHERE UserID = %s AND PartyID = %s", interaction.user.id, self.party.party_id))] + reason = "play your 1s match." + view = UserDropdownView(self, message_interaction=interaction, users=users, amount=1 if self.state in [SixMansState.CHOOSE_CAPTAIN_ONE, SixMansState.CHOOSE_1S_PLAYER] else 2 if self.state == SixMansState.CHOOSE_CAPTAIN_TWO else 0, reason=reason) + await interaction.response.send_message(view=view, ephemeral=True) + + async def break_out_button_callback(self, interaction: discord.Interaction): + await interaction.response.defer() + await self.create_break_out_rooms() + self.game = SixMansMatchType.ONE_V_ONE + DB.execute("INSERT INTO SixManGames () VALUES ()") + self.party.game_id = DB.field("SELECT LAST_INSERT_ID()") + DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", self.party.game_id, self.party.party_id) + await self.update_view() + + async def surrender_button_callback(self, interaction: discord.Interaction): + match_type = self.get_match_type() + DB.execute(f"UPDATE SixManGames SET {match_type}_{'A' if interaction.user.id == self.party.captain_one.id else 'B'} = %s WHERE GameID = %s", -1, self.party.game_id) + if self.party.calculate_winner() == 0: + self.game = SixMansMatchType.THREE_V_THREE if self.game == SixMansMatchType.TWO_V_TWO else SixMansMatchType.TWO_V_TWO + self.state = SixMansState.PLAYING + else: + self.state = SixMansState.POST_MATCH + await self.update_view() + return await interaction.response.send_message(f"You have surrendered the {match_type} match.", ephemeral=True) + + async def upload_scores_callback(self, interaction: discord.Interaction): + match_type = self.get_match_type() + await interaction.response.send_modal(ScoreUpload(self, match_type)) + + async def finish_button_callback(self, interaction: discord.Interaction): + await interaction.response.defer() + self.state = SixMansState.SCORE_UPLOAD + await self.update_view() + + async def team_composition_callback(self, interaction: discord.Interaction): + team_one_players = self.party.get_players(1) + team_two_players = self.party.get_players(2) + team_one_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] + team_two_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", "", None) + embed.add_field(name="Team A", value="\n".join(team_one_str)) + embed.add_field(name="Team B", value="\n".join(team_two_str)) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + await interaction.response.send_message(embed=embed, ephemeral=True) + +class ScoreUpload(Modal): + score_a = TextInput(label='Team A Score') + score_b = TextInput(label='Team B Score') + + def __init__(self, ctx: SixMansPrompt, title: str) -> None: + self.ctx = ctx + super().__init__(title=title) + + async def on_submit(self, interaction: discord.Interaction): + await interaction.response.defer() + match_type = self.ctx.get_match_type() + + if self.score_a.value == self.score_b.value: + return await interaction.followup.send("The team's score cannot be identical. Please re-input your values.", ephemeral=True) + elif (not self.score_a.value.isnumeric()) or (not self.score_b.value.isnumeric()): + return await interaction.followup.send("One or more of your scores is not valid. Please ensure they are non-negative numbers.", ephemeral=True) + else: + self.ctx.party.reported_scores[interaction.user.id][match_type] = (int(self.score_a.value), int(self.score_b.value)) + + # Check if both scores are present and compare + team_a_reported_score = self.ctx.party.reported_scores[self.ctx.party.captain_one.id][match_type] + team_b_reported_score = self.ctx.party.reported_scores[self.ctx.party.captain_two.id][match_type] + + if team_a_reported_score == team_b_reported_score: + # Both scores set and equal + DB.execute(f"UPDATE SixManGames SET {match_type}_A = %s, {match_type}_B = %s WHERE GameID = %s", team_a_reported_score[0], team_b_reported_score[1], self.ctx.party.game_id) + match self.ctx.game: + case SixMansMatchType.ONE_V_ONE: + self.ctx.game = SixMansMatchType.TWO_V_TWO + self.ctx.state = SixMansState.PLAYING + case SixMansMatchType.TWO_V_TWO: + if self.ctx.party.calculate_winner() == 0: + self.ctx.game = SixMansMatchType.THREE_V_THREE + self.ctx.state = SixMansState.PLAYING + else: + self.ctx.state = SixMansState.POST_MATCH + case SixMansMatchType.THREE_V_THREE: + self.ctx.state = SixMansState.POST_MATCH + await self.ctx.update_view() + +class SixMans(commands.Cog): + def __init__(self, bot: discord.Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.queue = list() + self.category = config["SIX_MAN"]["CATEGORY"] + + async def cog_load(self): + self.logger.info(f"[COG] Loaded {self.__class__.__name__}") + + async def check_queue(self): + if len(self.queue) == PARTY_SIZE: + party = self.queue[:PARTY_SIZE] + del self.queue[:PARTY_SIZE] + lobby_id, party_id = await self.create_party(party) + await self.start(lobby_id, party_id) + + async def generate_lobby_id(self): + highest_lobby_num = max([0] + (lobbies := DB.column("SELECT LobbyID FROM SixManParty WHERE LobbyID IS NOT NULL"))) + r = list(range(1, highest_lobby_num)) + possible_lobby_nums = [i for i in r if i not in lobbies] + return highest_lobby_num + 1 if not possible_lobby_nums else possible_lobby_nums.pop(0) + + async def create_party(self, members): + lobby_id = await self.generate_lobby_id() + lobby_name = f"SixMans #{lobby_id}" + + guild: discord.Guild = members[0].guild + + # Create role + lobby_role = await guild.create_role(name=lobby_name, reason=f"{lobby_name} created") + + # Create perms + vc_perms = {lobby_role: PermissionOverwrite(speak=True, connect=True, view_channel=True), guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + text_perms = {lobby_role: PermissionOverwrite(read_messages=True, send_messages=True), guild.default_role: PermissionOverwrite(read_messages=False)} + + # Create voice channel + voice_channel = await guild.create_voice_channel(name=lobby_name, overwrites=vc_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") + + # Create text channel + text_channel = await guild.create_text_channel(name=f"six-mans-{lobby_id}", overwrites=text_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") + + # Send preliminary starting message + message = await text_channel.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"SCUFFBOT is creating the six mans lobby. This message will update once complete.", None)) + + lobby_invite_link = str(await voice_channel.create_invite(reason=f"{lobby_name} created")) + + DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) + DB.execute("INSERT INTO SixManParty (LobbyID) VALUES (%s)", lobby_id) + party_id = DB.field("SELECT LAST_INSERT_ID()") + + # Invite members + for member in members: + await member.add_roles(lobby_role, reason=f"{member} added to {lobby_name}") + await member.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have been added into **{lobby_name}**.", None), view=View().add_item(discord.ui.Button(label="Join Six Mans",style=discord.ButtonStyle.link, url=lobby_invite_link))) + DB.execute("INSERT INTO SixManUsers (PartyID, UserID) VALUES (%s, %s)", party_id, member.id) + return (lobby_id, party_id) + + async def start(self, lobby_id: int, party_id: int): + text_channel = self.bot.get_channel(int(DB.field("SELECT TextChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) + message = await text_channel.fetch_message(int(DB.field("SELECT MessageID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) + view = SixMansPrompt(self.bot, party_id) + view.message = message + await view.update_view() + + @app_commands.command(name="q", description="Joins the six man queue.") + async def queue(self, interaction: discord.Interaction): + if interaction.user in self.queue: + self.queue.remove(interaction.user) + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) + else: + if str(interaction.user.id) in DB.column("SELECT A.UserID FROM SixManUsers A INNER JOIN SixManParty B WHERE A.PartyID = B.PartyID AND B.LobbyID IS NOT NULL"): + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You are already in a six mans lobby. Failed to join queue.", None), ephemeral=True) + else: + self.queue.append(interaction.user) + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.queue)}/{PARTY_SIZE})", None), ephemeral=True) + await self.check_queue() + + @app_commands.command(name="leave", description="Leaves the six man queue.") + async def leave(self, interaction: discord.Interaction): + if not interaction.user in self.queue: + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You are not in a queue. Type `/q` to join the queue.", None), ephemeral=True) + else: + self.queue.remove(interaction.user) + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) + + @app_commands.command(name="status", description="Returns the number of people in the queue.") + async def status(self, interaction: discord.Interaction): + queue_len = len(self.queue) + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"There {'is' if queue_len == 1 else 'are'} currently {len(self.queue)} {'player' if queue_len == 1 else 'players'} in the queue.", None)) + +async def setup(bot): + await bot.add_cog(SixMans(bot)) + + +class UserDropdown(Select): + def __init__(self, users: list[discord.Member], amount: int, reason: str): + options = [discord.SelectOption(label=user.display_name, value=user.id) for user in users] + super().__init__(placeholder=f"Choose {amount} {'member' if amount == 1 else 'members'} to {reason}", min_values=amount, max_values=amount, options=options) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer() + +class UserDropdownView(View): + def __init__(self, ctx: SixMansPrompt, message_interaction: discord.Interaction, users: list[discord.Member], amount: int, reason: str): + super().__init__(timeout=None) + self.ctx = ctx + self.message_interaction = message_interaction + self.reason = reason + + self.user_dropdown = UserDropdown(users, amount, reason) + self.add_item(self.user_dropdown) + + self.submit_button = discord.ui.Button(label="Submit", style=discord.ButtonStyle.green) + self.submit_button.callback = self.submit_button_callback + self.add_item(self.submit_button) + + async def submit_button_callback(self, interaction: discord.Interaction): + match self.ctx.state: + case SixMansState.CHOOSE_CAPTAIN_ONE: + for user_id in self.user_dropdown.values: + DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s AND PartyID = %s", user_id, self.ctx.party.party_id) + self.ctx.state = SixMansState.CHOOSE_CAPTAIN_TWO + case SixMansState.CHOOSE_CAPTAIN_TWO: + for user_id in self.user_dropdown.values: + DB.execute("UPDATE SixManUsers SET Team = 2 WHERE UserID = %s AND PartyID = %s", user_id, self.ctx.party.party_id) + # Add last player + last_player_id = DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.ctx.party.party_id) + DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s", last_player_id) + self.ctx.state = SixMansState.CHOOSE_1S_PLAYER + case SixMansState.CHOOSE_1S_PLAYER: + DB.execute("UPDATE SixManUsers SET isOnesPlayer = 1 WHERE UserID = %s AND PartyID = %s", self.user_dropdown.values[0], self.ctx.party.party_id) + if len(DB.rows("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1", self.ctx.party.party_id)) == 2: + DB.execute("UPDATE SixManUsers SET isOnesPlayer = 0 WHERE PartyID = %s AND isOnesPlayer IS NULL", self.ctx.party.party_id) + self.ctx.state = SixMansState.PLAYING + await self.message_interaction.edit_original_response(content=f"You have selected {', '.join([interaction.client.get_user(int(user_id)).mention for user_id in self.user_dropdown.values])} to {self.reason}", view=None) + await self.ctx.update_view() diff --git a/src/lib/db/__init__.py b/src/lib/db/__init__.py new file mode 100644 index 0000000..50ff4bc --- /dev/null +++ b/src/lib/db/__init__.py @@ -0,0 +1,46 @@ +import logging +import mysql.connector as mysql +from mysql.connector import errorcode +from dotenv import find_dotenv, load_dotenv +import os + +env_file = find_dotenv(".env.local") +load_dotenv(env_file) + +class DB: + def __init__(self) -> None: + self.logger = logging.getLogger(__name__) + + def connect(self): + try: + self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv("DB_USER"), password=os.getenv("DB_PASSWORD"), database=os.getenv("DB_DATABASE"), autocommit=True) + except mysql.Error as err: + if err.errno == errorcode.ER_ACCESS_DENIED_ERROR: + self.logger.error("[DB] Database credentials incorrect.") + elif err.errno == errorcode.ER_BAD_DB_ERROR: + self.logger.error("[DB] Database does not exist.") + else: + self.logger.error(f"[DB] {err}") + raise err + else: + self.cursor = self.connection.cursor(dictionary=True) + self.logger.info("[DB] Connection established.") + + def execute(self, command, *values): + self.cursor.execute(command, tuple(values)) + + def field(self, command, *values): + self.cursor.execute(command, tuple(values)) + return None if not (data := self.cursor.fetchone()) else list(data.values())[0] + + def row(self, command, *values): + self.cursor.execute(command, tuple(values)) + return self.cursor.fetchone() + + def rows(self, command, *values): + self.cursor.execute(command, tuple(values)) + return self.cursor.fetchall() + + def column(self, command, *values): + self.cursor.execute(command, tuple(values)) + return [list(row.values())[0] for row in self.cursor.fetchall()] diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py new file mode 100644 index 0000000..5ee3c68 --- /dev/null +++ b/src/utils/SixMans.py @@ -0,0 +1,64 @@ +from enum import Enum +import random +from typing import Literal, Union + +import discord +from src.lib.bot import DB + +PARTY_SIZE = 6 + +class SixMansState(Enum): + CHOOSE_CAPTAIN_ONE = 1 + CHOOSE_CAPTAIN_TWO = 2 + CHOOSE_1S_PLAYER = 3 + PLAYING = 4 + SCORE_UPLOAD = 5 + POST_MATCH = 6 + +class SixMansMatchType(Enum): + PRE_MATCH = 0 + ONE_V_ONE = 1 + TWO_V_TWO = 2 + THREE_V_THREE = 3 + +class SixMansParty(): + def __init__(self, bot: discord.Client, party_id: int) -> None: + self.bot = bot + self.game_id: Union[None, int] = None + self.party_id = party_id + self.lobby_id = DB.field("SELECT LobbyID FROM SixManParty WHERE PartyID = %s", self.party_id) + self.players = self.get_players() + self.captain_one: Union[None, discord.Member] = None + self.captain_two: Union[None, discord.Member] = None + self.generate_captains() + + self.reported_scores = {self.captain_one.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}, self.captain_two.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}} + + async def get_details(self): + return DB.row("SELECT * FROM SixManLobby WHERE LobbyID = %s", self.lobby_id) + + def get_players(self, team: Literal[None, 1, 2] = None): + return [self.bot.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party_id, 0 if team == None else team)] + + def generate_captains(self): + players = self.get_players() + self.captain_one = players.pop(random.randint(0, len(players)-1)) + self.captain_two = players.pop(random.randint(0, len(players)-1)) + DB.execute("UPDATE SixManUsers SET Type = 1, Team = 1 WHERE UserID = %s", self.captain_one.id) + DB.execute("UPDATE SixManUsers SET Type = 2, Team = 2 WHERE UserID = %s", self.captain_two.id) + + def calculate_winner(self) -> Literal[0, 1, 2]: + if self.game_id == None: + return 0 + data = [0 if x is None else x for x in DB.row("SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.game_id).values()] + + team_a_wins = sum(data[i] > data[i + 1] for i in range(0, len(data), 2)) + team_b_wins = sum(data[i + 1] > data[i] for i in range(0, len(data), 2)) + + match (team_a_wins >= 2, team_b_wins >= 2): + case (True, False): + return 1 + case (False, True): + return 2 + case (False, False): + return 0 From 3cefdec607c7c094ffd00bc3c5a21d7b5645c51b Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Fri, 24 Jan 2025 20:09:21 +1100 Subject: [PATCH 02/26] Fixed local deployment + password files not resolving --- .env.local | 6 +++--- db/.env.template | 4 ++-- docker-compose.yaml | 13 +++++++++++-- infra/values.yaml | 8 ++++---- src/lib/bot/__init__.py | 22 ++++++++++++++-------- src/lib/db/__init__.py | 11 ++++++++--- 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/.env.local b/.env.local index de6af98..7136b13 100644 --- a/.env.local +++ b/.env.local @@ -1,6 +1,6 @@ -BOT_TOKEN=.local-secrets/bot-token -CONFIG_FILE=config.yaml.local +BOT_TOKEN=/secrets/bot-token +CONFIG_FILE=/config/config.yaml.local DB_DATABASE=SCUFFBOT DB_HOST=db -DB_PASSWORD=.local-secrets/db-password +DB_PASSWORD_FILE=/secrets/db-password DB_USER=SCUFFBOT diff --git a/db/.env.template b/db/.env.template index 3561728..b857555 100644 --- a/db/.env.template +++ b/db/.env.template @@ -1,4 +1,4 @@ MYSQL_DATABASE=SCUFFBOT MYSQL_USER=SCUFFBOT -MYSQL_PASSWORD=aStrongPassword -MYSQL_ROOT_PASSWORD=anotherStrongPassword +MYSQL_PASSWORD_FILE=/secrets/db-password +MYSQL_ROOT_PASSWORD_FILE=/secrets/db-root-password diff --git a/docker-compose.yaml b/docker-compose.yaml index 23495c8..20a285d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,7 +4,8 @@ services: build: ./ restart: unless-stopped volumes: - - ./:/app + - ./.local-secrets:/secrets + - ./config.yaml.local:/config/config.yaml.local depends_on: db: condition: service_healthy @@ -14,15 +15,23 @@ services: - backend # MySQL Server db: - image: "mysql/mysql-server:8.0" + image: "mysql:9.2.0" restart: unless-stopped env_file: - ./db/.env volumes: - db_data:/var/lib/mysql - ./db/data:/docker-entrypoint-initdb.d/ + - .local-secrets/db-password:/secrets/db-password + - .local-secrets/db-root-password:/secrets/db-root-password networks: - backend + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"] + interval: 5s + timeout: 3s + retries: 2 + start_period: 0s # phpmyadmin phpmyadmin: depends_on: diff --git a/infra/values.yaml b/infra/values.yaml index 0a3101c..03c28fb 100644 --- a/infra/values.yaml +++ b/infra/values.yaml @@ -12,13 +12,13 @@ deployments: CONFIG_FILE: /config/config.yaml DB_DATABASE: SCUFFBOT DB_HOST: scuffbot-db - DB_PASSWORD: /secrets/db-password + DB_PASSWORD_FILE: /secrets/db-password DB_USER: SCUFFBOT secrets: bot-token: "${BOT_TOKEN}" db-password: "${DB_PASSWORD}" - name: "scuffbot-db" - image: "mysql/mysql-server:8.0" + image: "mysql:9.2.0" config: fileName: "init.sql" fileVariable: sqlInitFile @@ -27,8 +27,8 @@ deployments: environment: MYSQL_DATABASE: SCUFFBOT MYSQL_USER: SCUFFBOT - MYSQL_PASSWORD: /secrets/db-password - MYSQL_ROOT_PASSWORD: /secrets/db-root-password + MYSQL_PASSWORD_FILE: /secrets/db-password + MYSQL_ROOT_PASSWORD_FILE: /secrets/db-root-password readinessProbe: tcpSocket: port: 3306 diff --git a/src/lib/bot/__init__.py b/src/lib/bot/__init__.py index 8fef474..e47f646 100644 --- a/src/lib/bot/__init__.py +++ b/src/lib/bot/__init__.py @@ -18,10 +18,12 @@ DB = SCUFF_DB() + class SCUFFBOT(commands.Bot): def __init__(self, is_dev): - super().__init__(command_prefix="!", owner_id=169402073404669952, intents=discord.Intents.all()) + super().__init__(command_prefix="!", + owner_id=169402073404669952, intents=discord.Intents.all()) self.is_dev = is_dev self.mode = "DEVELOPMENT" if is_dev else "PRODUCTION" @@ -33,8 +35,8 @@ async def setup_hook(self): except Exception as e: self.logger.error(f"Failed to connect to the database: {e}") sys.exit(1) - await self.load_cog_manager() - + await self.load_cog_manager() + def setup_logger(self): with open("./logging.json") as f: logging.config.dictConfig(json.loads(f.read())) @@ -51,14 +53,17 @@ def run(self, bot_token: str): super().run(bot_token, log_handler=None) def create_embed(self, title, description, colour): - embed = discord.Embed(title=None, description=description, colour=colour if colour else 0xDC3145, timestamp=discord.utils.utcnow()) - embed.set_author(name=title if title else None, icon_url=self.avatar_url) + embed = discord.Embed(title=None, description=description, + colour=colour if colour else 0xDC3145, timestamp=discord.utils.utcnow()) + embed.set_author(name=title if title else None, + icon_url=self.avatar_url) return embed # Doesn't work, need to look into @staticmethod def has_permissions(**perms): original = app_commands.checks.has_permissions(**perms) + async def extended_check(interaction): if interaction.guild is None: return False @@ -73,15 +78,16 @@ async def on_ready(self): self.appinfo = await super().application_info() self.avatar_url = self.appinfo.icon.url if self.appinfo.icon is not None else None self.logger.info( - f"Connected on {self.user.name} ({self.mode}) | d.py v{str(discord.__version__)}" + f"Connected on {self.user.name} | d.py v{str(discord.__version__)}" ) async def on_interaction(self, interaction): - self.logger.info(f"[COMMAND] [{interaction.guild} // {interaction.guild.id}] {interaction.user} ({interaction.user.id}) used command {interaction.command.name}") + self.logger.info( + f"[COMMAND] [{interaction.guild} // {interaction.guild.id}] {interaction.user} ({interaction.user.id}) used command {interaction.command.name}") async def on_message(self, message): await self.wait_until_ready() - if(isinstance(message.channel, discord.DMChannel) and message.author.id == self.owner_id): + if (isinstance(message.channel, discord.DMChannel) and message.author.id == self.owner_id): message_components = message.content.lower().split(" ") match message_components[0]: case "sync": diff --git a/src/lib/db/__init__.py b/src/lib/db/__init__.py index 50ff4bc..99e8a67 100644 --- a/src/lib/db/__init__.py +++ b/src/lib/db/__init__.py @@ -7,13 +7,18 @@ env_file = find_dotenv(".env.local") load_dotenv(env_file) + class DB: def __init__(self) -> None: self.logger = logging.getLogger(__name__) def connect(self): + with open(os.getenv("DB_PASSWORD_FILE"), "r", encoding="utf-8") as f: + password = f.read().strip() + print(password, flush=True) try: - self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv("DB_USER"), password=os.getenv("DB_PASSWORD"), database=os.getenv("DB_DATABASE"), autocommit=True) + self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv( + "DB_USER"), password=password, database=os.getenv("DB_DATABASE"), autocommit=True) except mysql.Error as err: if err.errno == errorcode.ER_ACCESS_DENIED_ERROR: self.logger.error("[DB] Database credentials incorrect.") @@ -32,11 +37,11 @@ def execute(self, command, *values): def field(self, command, *values): self.cursor.execute(command, tuple(values)) return None if not (data := self.cursor.fetchone()) else list(data.values())[0] - + def row(self, command, *values): self.cursor.execute(command, tuple(values)) return self.cursor.fetchone() - + def rows(self, command, *values): self.cursor.execute(command, tuple(values)) return self.cursor.fetchall() From c6f91ea96a4eb6c8450c8e5f308661660a2c1eeb Mon Sep 17 00:00:00 2001 From: Sam Zheng Date: Fri, 24 Jan 2025 23:22:10 +1300 Subject: [PATCH 03/26] =?UTF-8?q?=F0=9F=94=A7=20Update=20`values.yaml`=20t?= =?UTF-8?q?o=20support=20DB=20statefulset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Helm chart template was recently updated to support stateful sets. Updating the `values.yaml` to move the DB to using a stateful set, which makes more sense for a database and should hopefully resolve the strange issues that ScuffBot has been experiencing in production. --- .github/workflows/deploy.yaml | 2 +- infra/values.yaml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 33ae838..7a36310 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -101,6 +101,6 @@ jobs: BOT_TOKEN: ${{ needs.build.outputs.environment == 'staging' && secrets.BOT_TOKEN_STAGING || secrets.BOT_TOKEN_PROD }} run: | cat infra/values.yaml | envsubst | \ - helm upgrade --install "$REPO_NAME" chartmuseum/discord-bot --version 0.1.0 \ + helm upgrade --install "$REPO_NAME" chartmuseum/discord-bot --version 0.2.0 \ -f - --set-file scuffbotConfig=config.yaml --set-file sqlInitFile=db/data/init.sql \ --namespace="$REPO_NAME-$ENVIRONMENT" --create-namespace diff --git a/infra/values.yaml b/infra/values.yaml index 03c28fb..407ea5e 100644 --- a/infra/values.yaml +++ b/infra/values.yaml @@ -17,6 +17,8 @@ deployments: secrets: bot-token: "${BOT_TOKEN}" db-password: "${DB_PASSWORD}" + +statefulSets: - name: "scuffbot-db" image: "mysql:9.2.0" config: @@ -37,6 +39,7 @@ deployments: db-root-password: "${DB_ROOT_PASSWORD}" service: type: ClusterIP + headless: true port: 3306 volumes: - name: "scuffbot-db" From de2b4565124461ab55f03aac337296a49025aa0a Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Fri, 24 Jan 2025 21:55:41 +1100 Subject: [PATCH 04/26] Remove extraneous print --- src/lib/db/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/db/__init__.py b/src/lib/db/__init__.py index 99e8a67..fb40caf 100644 --- a/src/lib/db/__init__.py +++ b/src/lib/db/__init__.py @@ -15,7 +15,6 @@ def __init__(self) -> None: def connect(self): with open(os.getenv("DB_PASSWORD_FILE"), "r", encoding="utf-8") as f: password = f.read().strip() - print(password, flush=True) try: self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv( "DB_USER"), password=password, database=os.getenv("DB_DATABASE"), autocommit=True) From 165621d5ea8372c6446b3be7d89e8c40a47867dd Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Sat, 25 Jan 2025 01:48:31 +1100 Subject: [PATCH 05/26] Added queue timeout, renamed Team A/B with team name, queue prompt --- README.md | 13 ++ src/lib/cogs/SixMans.py | 457 ++++++++++++++++++++++++++-------------- src/utils/SixMans.py | 57 ++++- 3 files changed, 363 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index b50d506..14fed99 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,16 @@ If you would like to run locally: | `db-root-password` | Root password for the database | 4. Copy [/db/.env.template](./db/.env.template) to `db/.env`, and adjust values as necessary. 5. Run the bot with `docker compose up -d --build`. + +# Feedback + +- [x] Implement a queue timeout, perhaps 45-60mins? +- [x] Notify the channel when a player has joined the queue +- [ ] Have options to do a 1s (best of 1), 2s (best of 1), 3s (best of 3) and/or have the option to configure the game to be a best of 1 or best of 3 +- [ ] Have request to spectate six mans matches +- [x] Wait for all 6 people to join call before starting otherwise cancel after 5 mins +- [ ] Ability to substitute players into the game? +- [ ] Incorporate personal stats page +- [x] Disable general text chat messaging +- [x] Change Team A/B to actual team names +- [ ] Add rematch button diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index c3fd835..5465bb2 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -1,3 +1,4 @@ +import asyncio from discord.ext import commands from discord.ui import Button, View, Select, Modal, TextInput from discord import app_commands, PermissionOverwrite, Object @@ -6,21 +7,36 @@ import discord import logging -from src.utils.SixMans import PARTY_SIZE, SixMansState, SixMansMatchType, SixMansParty +from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, QUEUE_TIMEOUT, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty + class SixMansPrompt(View): def __init__(self, bot: discord.Client, party_id: int): super().__init__(timeout=None) self.bot = bot self.message: Union[None | discord.Message] = None - - self.state = SixMansState.CHOOSE_CAPTAIN_ONE + + self.state = SixMansState.PRE_LOBBY self.game = SixMansMatchType.PRE_MATCH - + self.party = SixMansParty(bot, party_id) - + self.bot.event(self.on_voice_state_update) + + @commands.Cog.listener() + async def on_voice_state_update(self, member, before, after): + if before.channel is not None and after.channel is not None and before.channel == after.channel: + return + if member.bot: + return + + # User joins channel + if str(after.channel.id) == (await self.party.get_details())["VoiceChannelID"] and len(after.channel.members) == PARTY_SIZE: + self.state = SixMansState.CHOOSE_CAPTAIN_ONE + await self.update_view() + async def interaction_check(self, interaction: discord.Interaction): - if "custom_id" in interaction.data and interaction.data["custom_id"] == "team_comp": return True + if "custom_id" in interaction.data and interaction.data["custom_id"] == "team_comp": + return True match self.state: case SixMansState.CHOOSE_CAPTAIN_ONE: if interaction.user and interaction.user.id != self.party.captain_one.id: @@ -34,21 +50,23 @@ async def interaction_check(self, interaction: discord.Interaction): if interaction.user and interaction.user.id not in [self.party.captain_one.id, self.party.captain_two.id]: await interaction.response.send_message(f"Only {self.party.captain_one.mention} or {self.party.captain_two.mention} can do this.", ephemeral=True) return interaction.user and interaction.user.id in [self.party.captain_one.id, self.party.captain_two.id] - + async def delete_lobby(self): lobby_details = await self.party.get_details() lobby_name = f"SixMans #{self.party.lobby_id}" - - guild = self.bot.get_channel(int(lobby_details["VoiceChannelID"])).guild + + guild = self.bot.get_channel( + int(lobby_details["VoiceChannelID"])).guild await (guild.get_role(int(lobby_details["RoleID"]))).delete(reason=f"{lobby_name} finished/cancelled") await (self.bot.get_channel(int(lobby_details["VoiceChannelID"]))).delete(reason=f"{lobby_name} finished/cancelled") await (self.bot.get_channel(int(lobby_details["TextChannelID"]))).delete(reason=f"{lobby_name} finished/cancelled") - + if lobby_details["VoiceChannelA"]: await (self.bot.get_channel(int(lobby_details["VoiceChannelA"]))).delete(reason=f"{lobby_name} finished/cancelled") if lobby_details["VoiceChannelB"]: await (self.bot.get_channel(int(lobby_details["VoiceChannelB"]))).delete(reason=f"{lobby_name} finished/cancelled") - DB.execute("DELETE FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id) + DB.execute("DELETE FROM SixManLobby WHERE LobbyID = %s", + self.party.lobby_id) def get_match_type(self): match self.game: @@ -59,7 +77,7 @@ def get_match_type(self): case SixMansMatchType.THREE_V_THREE: match_type = "3v3" return match_type - + async def create_break_out_rooms(self): lobby_a = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_one.display_name}" lobby_b = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_two.display_name}" @@ -68,14 +86,17 @@ async def create_break_out_rooms(self): team_two_players = self.party.get_players(2) guild: discord.Guild = self.message.guild - voice_channel_a_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True,connect=True,view_channel=True)), team_one_players))) | {guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} - voice_channel_b_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True,connect=True,view_channel=True)), team_two_players))) | {guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + voice_channel_a_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True, connect=True, view_channel=True)), team_one_players))) | { + guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + voice_channel_b_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True, connect=True, view_channel=True)), team_two_players))) | { + guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} voice_channel_a = await guild.create_voice_channel(name=lobby_a, overwrites=voice_channel_a_perms, category=self.message.channel.category, reason=f"{lobby_a} created") voice_channel_b = await guild.create_voice_channel(name=lobby_b, overwrites=voice_channel_b_perms, category=self.message.channel.category, reason=f"{lobby_b} created") - DB.execute("UPDATE SixManLobby SET VoiceChannelA = %s, VoiceChannelB = %s WHERE LobbyID = %s", voice_channel_a.id, voice_channel_b.id, self.party.lobby_id) - + DB.execute("UPDATE SixManLobby SET VoiceChannelA = %s, VoiceChannelB = %s WHERE LobbyID = %s", + voice_channel_a.id, voice_channel_b.id, self.party.lobby_id) + # Move members for member in guild.get_channel(int(DB.field("SELECT VoiceChannelID FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id))).members: if member in team_one_players: @@ -84,7 +105,8 @@ async def create_break_out_rooms(self): await member.move_to(voice_channel_b) def generate_flag_str(self, member: discord.Member): - data = DB.row("SELECT Type, isOnesPlayer FROM SixManUsers WHERE PartyID = %s AND UserID = %s", self.party.party_id, member.id) + data = DB.row("SELECT Type, isOnesPlayer FROM SixManUsers WHERE PartyID = %s AND UserID = %s", + self.party.party_id, member.id) flags = [] match data["Type"]: case 1 | 2: @@ -99,58 +121,90 @@ def generate_flag_str(self, member: discord.Member): return ", ".join(flags) def generate_match_summary(self) -> str: - scores = [0 if x is None else x for x in DB.row("SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.party.game_id).values()] - team_a = ['W' if x else '-' for x in [scores[i] > scores[i + 1] for i in range(0, len(scores), 2)]] - team_b = ['W' if x else '-' for x in [scores[i+1] > scores[i] for i in range(0, len(scores), 2)]] - - return (f"""``` -| | 1v1 | 2v2 | 3v3 | -|--------|-----|-----|-----| -| Team A | {team_a[0]} | {team_a[1]} | {team_a[2]} | -| Team B | {team_b[0]} | {team_b[1]} | {team_b[2]} |```""") - + scores = [0 if x is None else x for x in DB.row( + "SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.party.game_id).values()] + team_a = ['W' if x else '-' for x in [scores[i] > scores[i + 1] + for i in range(0, len(scores), 2)]] + team_b = ['W' if x else '-' for x in [scores[i+1] > scores[i] + for i in range(0, len(scores), 2)]] + name_1 = f"Team {self.party.captain_one.display_name}" + name_2 = f"Team {self.party.captain_two.display_name}" + max_len = max(len(name_1), len(name_2)) + + return f"""``` +| {' '* (max_len)} | 1v1 | 2v2 | 3v3 | +| {'-'* (max_len)} |-----|-----|-----| +| {name_1:<{max_len}} | {team_a[0]} | {team_a[1]} | {team_a[2]} | +| {name_2:<{max_len}} | {team_b[0]} | {team_b[1]} | {team_b[2]} |```""" + def generate_match_composition(self) -> str: - players = DB.rows("SELECT UserID, Type, Team, isOnesPlayer FROM SixManUsers WHERE PartyID = %s", self.party.party_id) - - ones_a = [row["UserID"] for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 1] - ones_b = [row["UserID"] for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 2] - - twos_a = [row["UserID"] for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 1] - twos_b = [row["UserID"] for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 2] - + players = DB.rows( + "SELECT UserID, Type, Team, isOnesPlayer FROM SixManUsers WHERE PartyID = %s", self.party.party_id) + + ones_a = [row["UserID"] + for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 1] + ones_b = [row["UserID"] + for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 2] + + twos_a = [row["UserID"] + for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 1] + twos_b = [row["UserID"] + for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 2] + threes_a = [row["UserID"] for row in players if row["Team"] == 1] threes_b = [row["UserID"] for row in players if row["Team"] == 2] return (f"""### 1v1 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.ONE_V_ONE else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_a]) if ones_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_b]) if ones_b else 'TBD'} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_a]) if ones_a else 'TBD'} **vs** { + ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_b]) if ones_b else 'TBD'} ### 2v2 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.TWO_V_TWO else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_a]) if twos_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_b]) if twos_b else 'TBD'} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_a]) if twos_a else 'TBD'} **vs** { + ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_b]) if twos_b else 'TBD'} ### 3v3 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.THREE_V_THREE else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_a]) if threes_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_b]) if threes_b else 'TBD'} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_a]) if threes_a else 'TBD'} **vs** { + ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_b]) if threes_b else 'TBD'} """) def generate_embed(self): team_one_players = self.party.get_players(1) team_two_players = self.party.get_players(2) - team_one_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] - team_two_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] + team_one_str = [ + f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] + team_two_str = [ + f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] match self.state: + case SixMansState.PRE_LOBBY: + description = f"Hello! Welcome to {self.bot.user.mention} Six Mans!\n\nSix Mans will start once all players have joined the voice channel. Otherwise, if all players have not connected within {LOBBY_TIMEOUT} minutes, the lobby will automatically be deleted.\n\n" + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed case SixMansState.CHOOSE_CAPTAIN_ONE: description = f"Hello! Welcome to {self.bot.user.mention} Six Mans!\n\nTo begin, {self.party.captain_one.mention} needs to select **ONE** player to join their team.\n\n" - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) - embed.add_field(name="Team A", value="\n".join(team_one_str)) - embed.add_field(name="Team B", value="\n".join(team_two_str)) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field( + name=f"Team {self.party.captain_one.display_name}", value="\n".join(team_one_str)) + embed.add_field( + name=f"Team {self.party.captain_two.display_name}", value="\n".join(team_two_str)) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") return embed case SixMansState.CHOOSE_CAPTAIN_TWO: description = f"{self.party.captain_two.mention} now needs to select **TWO** players to join their team.\n\n" - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) - embed.add_field(name="Team A", value="\n".join(team_one_str)) - embed.add_field(name="Team B", value="\n".join(team_two_str)) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field( + name=f"Team {self.party.captain_one.display_name}", value="\n".join(team_one_str)) + embed.add_field( + name=f"Team {self.party.captain_two.display_name}", value="\n".join(team_two_str)) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") return embed case SixMansState.CHOOSE_1S_PLAYER: - ones_players = (DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 1", self.party.party_id), DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 2", self.party.party_id)) + ones_players = (DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 1", self.party.party_id), DB.field( + "SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 2", self.party.party_id)) if ones_players[0] == None and ones_players[1] == None: waiting_message = "both team captains to nominate a player.**" elif ones_players[0] == None and ones_players[1] != None: @@ -158,10 +212,14 @@ def generate_embed(self): else: waiting_message = f"{self.party.captain_two.mention} to nominate a player.**" description = f"Perfect! We now have our teams sorted. We will start off with the 1v1 match.\n\nTo begin, each team captain needs to nominate a player from their own team to play their 1s match. You can do this by clicking the **Nominate player** button below.\n\n**Currently waiting on {waiting_message}" - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) - embed.add_field(name="Team A", value="\n".join(team_one_str)) - embed.add_field(name="Team B", value="\n".join(team_two_str)) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field( + name=f"Team {self.party.captain_one.display_name}", value="\n".join(team_one_str)) + embed.add_field( + name=f"Team {self.party.captain_two.display_name}", value="\n".join(team_two_str)) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") return embed case SixMansState.PLAYING: match self.game: @@ -172,7 +230,7 @@ def generate_embed(self): description = f"You are now playing the {match_type} match. **Don't forget to come back here before your next game starts.**\n\n{self.generate_match_summary()}\n{self.generate_match_composition()}\n\nOnce you have finished your {match_type} match, click on the **Finish {match_type}** button below. Best of luck!" case SixMansState.SCORE_UPLOAD: match_type = self.get_match_type() - + team_a_reported_score = self.party.reported_scores[self.party.captain_one.id][match_type] team_b_reported_score = self.party.reported_scores[self.party.captain_two.id][match_type] @@ -189,14 +247,16 @@ def generate_embed(self): case SixMansState.POST_MATCH: match self.party.calculate_winner(): case 1: - winner = "A" + winner = self.party.captain_one.display_name case 2: - winner = "B" + winner = self.party.captain_two.display_name description = f"### Congratulations Team {winner}!\nYou have won the six mans scrims!\n\nPress the **End Game** button below when you are done. Thanks for playing!" case _: description = "Uh oh! Something has gone wrong." - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") return embed def add_button(self, label: str, style: discord.ButtonStyle, callback: Callable[[discord.Interaction], Any], **kwargs): @@ -208,26 +268,38 @@ def update_options(self): self.clear_items() match self.state: case SixMansState.CHOOSE_CAPTAIN_ONE | SixMansState.CHOOSE_CAPTAIN_TWO: - self.add_button("Choose Player(s)", discord.ButtonStyle.blurple, self.choose_button_callback) - self.add_button("Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) + self.add_button( + "Choose Player(s)", discord.ButtonStyle.blurple, self.choose_button_callback) + self.add_button( + "Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) case SixMansState.CHOOSE_1S_PLAYER: - self.add_button("Nominate Player", discord.ButtonStyle.blurple, self.choose_button_callback) - self.add_button("Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) + self.add_button( + "Nominate Player", discord.ButtonStyle.blurple, self.choose_button_callback) + self.add_button( + "Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) case SixMansState.PLAYING: match self.game: case SixMansMatchType.PRE_MATCH: - self.add_button("Break Out", discord.ButtonStyle.blurple, self.break_out_button_callback) - self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + self.add_button( + "Break Out", discord.ButtonStyle.blurple, self.break_out_button_callback) + self.add_button("View Team Composition", discord.ButtonStyle.grey, + self.team_composition_callback, custom_id="team_comp") case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: match_label = self.get_match_type() - self.add_button(f"Finish {match_label}", discord.ButtonStyle.blurple, self.finish_button_callback) - self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") - self.add_button(f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) + self.add_button( + f"Finish {match_label}", discord.ButtonStyle.blurple, self.finish_button_callback) + self.add_button("View Team Composition", discord.ButtonStyle.grey, + self.team_composition_callback, custom_id="team_comp") + self.add_button( + f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) case SixMansState.SCORE_UPLOAD: - self.add_button("Upload Scores", discord.ButtonStyle.blurple, self.upload_scores_callback) - self.add_button("View team composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + self.add_button( + "Upload Scores", discord.ButtonStyle.blurple, self.upload_scores_callback) + self.add_button("View team composition", discord.ButtonStyle.grey, + self.team_composition_callback, custom_id="team_comp") case SixMansState.POST_MATCH: - self.add_button("End Game", discord.ButtonStyle.red, self.cancel_button_callback) + self.add_button("End Game", discord.ButtonStyle.red, + self.cancel_button_callback) async def update_view(self, embed=None): self.update_options() @@ -236,30 +308,35 @@ async def update_view(self, embed=None): async def cancel_button_callback(self, _: discord.Interaction): await self.delete_lobby() - + async def choose_button_callback(self, interaction: discord.Interaction): match self.state: case SixMansState.CHOOSE_CAPTAIN_ONE | SixMansState.CHOOSE_CAPTAIN_TWO: - users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.party.party_id)] + users = [interaction.client.get_user(int(user_id)) for user_id in DB.column( + "SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.party.party_id)] reason = "join your team." case SixMansState.CHOOSE_1S_PLAYER: - users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party.party_id, DB.field("SELECT Team FROM SixManUsers WHERE UserID = %s AND PartyID = %s", interaction.user.id, self.party.party_id))] + users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party.party_id, DB.field( + "SELECT Team FROM SixManUsers WHERE UserID = %s AND PartyID = %s", interaction.user.id, self.party.party_id))] reason = "play your 1s match." - view = UserDropdownView(self, message_interaction=interaction, users=users, amount=1 if self.state in [SixMansState.CHOOSE_CAPTAIN_ONE, SixMansState.CHOOSE_1S_PLAYER] else 2 if self.state == SixMansState.CHOOSE_CAPTAIN_TWO else 0, reason=reason) + view = UserDropdownView(self, message_interaction=interaction, users=users, amount=1 if self.state in [ + SixMansState.CHOOSE_CAPTAIN_ONE, SixMansState.CHOOSE_1S_PLAYER] else 2 if self.state == SixMansState.CHOOSE_CAPTAIN_TWO else 0, reason=reason) await interaction.response.send_message(view=view, ephemeral=True) - + async def break_out_button_callback(self, interaction: discord.Interaction): await interaction.response.defer() await self.create_break_out_rooms() self.game = SixMansMatchType.ONE_V_ONE DB.execute("INSERT INTO SixManGames () VALUES ()") self.party.game_id = DB.field("SELECT LAST_INSERT_ID()") - DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", self.party.game_id, self.party.party_id) + DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", + self.party.game_id, self.party.party_id) await self.update_view() - + async def surrender_button_callback(self, interaction: discord.Interaction): match_type = self.get_match_type() - DB.execute(f"UPDATE SixManGames SET {match_type}_{'A' if interaction.user.id == self.party.captain_one.id else 'B'} = %s WHERE GameID = %s", -1, self.party.game_id) + DB.execute( + f"UPDATE SixManGames SET {match_type}_{'A' if interaction.user.id == self.party.captain_one.id else 'B'} = %s WHERE GameID = %s", -1, self.party.game_id) if self.party.calculate_winner() == 0: self.game = SixMansMatchType.THREE_V_THREE if self.game == SixMansMatchType.TWO_V_TWO else SixMansMatchType.TWO_V_TWO self.state = SixMansState.PLAYING @@ -267,34 +344,45 @@ async def surrender_button_callback(self, interaction: discord.Interaction): self.state = SixMansState.POST_MATCH await self.update_view() return await interaction.response.send_message(f"You have surrendered the {match_type} match.", ephemeral=True) - + async def upload_scores_callback(self, interaction: discord.Interaction): match_type = self.get_match_type() await interaction.response.send_modal(ScoreUpload(self, match_type)) - + async def finish_button_callback(self, interaction: discord.Interaction): await interaction.response.defer() self.state = SixMansState.SCORE_UPLOAD await self.update_view() - + async def team_composition_callback(self, interaction: discord.Interaction): team_one_players = self.party.get_players(1) team_two_players = self.party.get_players(2) - team_one_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] - team_two_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", "", None) - embed.add_field(name="Team A", value="\n".join(team_one_str)) - embed.add_field(name="Team B", value="\n".join(team_two_str)) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + team_one_str = [ + f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] + team_two_str = [ + f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", "", None) + embed.add_field( + name=f"Team {self.party.captain_one.display_name}", value="\n".join(team_one_str)) + embed.add_field( + name=f"Team {self.party.captain_two.display_name}", value="\n".join(team_two_str)) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") await interaction.response.send_message(embed=embed, ephemeral=True) - + + class ScoreUpload(Modal): - score_a = TextInput(label='Team A Score') - score_b = TextInput(label='Team B Score') + score_a = TextInput( + label=f'Team A Score') + score_b = TextInput( + label=f'Team B Score') def __init__(self, ctx: SixMansPrompt, title: str) -> None: - self.ctx = ctx super().__init__(title=title) + self.ctx = ctx + self.score_a.label = f'Team {self.ctx.party.captain_one.display_name} Score' + self.score_b.label = f'Team {self.ctx.party.captain_two.display_name} Score' async def on_submit(self, interaction: discord.Interaction): await interaction.response.defer() @@ -305,15 +393,19 @@ async def on_submit(self, interaction: discord.Interaction): elif (not self.score_a.value.isnumeric()) or (not self.score_b.value.isnumeric()): return await interaction.followup.send("One or more of your scores is not valid. Please ensure they are non-negative numbers.", ephemeral=True) else: - self.ctx.party.reported_scores[interaction.user.id][match_type] = (int(self.score_a.value), int(self.score_b.value)) + self.ctx.party.reported_scores[interaction.user.id][match_type] = ( + int(self.score_a.value), int(self.score_b.value)) # Check if both scores are present and compare - team_a_reported_score = self.ctx.party.reported_scores[self.ctx.party.captain_one.id][match_type] - team_b_reported_score = self.ctx.party.reported_scores[self.ctx.party.captain_two.id][match_type] + team_a_reported_score = self.ctx.party.reported_scores[ + self.ctx.party.captain_one.id][match_type] + team_b_reported_score = self.ctx.party.reported_scores[ + self.ctx.party.captain_two.id][match_type] if team_a_reported_score == team_b_reported_score: # Both scores set and equal - DB.execute(f"UPDATE SixManGames SET {match_type}_A = %s, {match_type}_B = %s WHERE GameID = %s", team_a_reported_score[0], team_b_reported_score[1], self.ctx.party.game_id) + DB.execute(f"UPDATE SixManGames SET {match_type}_A = %s, {match_type}_B = %s WHERE GameID = %s", + team_a_reported_score[0], team_b_reported_score[1], self.ctx.party.game_id) match self.ctx.game: case SixMansMatchType.ONE_V_ONE: self.ctx.game = SixMansMatchType.TWO_V_TWO @@ -328,29 +420,89 @@ async def on_submit(self, interaction: discord.Interaction): self.ctx.state = SixMansState.POST_MATCH await self.ctx.update_view() + +class QueuePrompt(View): + + def __init__(self, ctx, message: discord.Message): + super().__init__(timeout=None) + self.ctx = ctx + self.message = message + + join_button = Button( + label="Join Queue", style=discord.ButtonStyle.green) + join_button.callback = self.join_callback + self.add_item(join_button) + + leave_button = Button( + label="Leave Queue", style=discord.ButtonStyle.grey) + leave_button.callback = self.leave_callback + self.add_item(leave_button) + + def generate_embed(self): + queue_len = len(self.ctx.player_queue) + return self.ctx.bot.create_embed("SCUFFBOT SIX MANS", f"To join/leave the six man queue, click the respective button below.\n\nThere {'is' if queue_len == 1 else 'are'} currently **{queue_len} {'player' if queue_len == 1 else 'players'}** in the queue.", None) + + async def update_view(self, embed=None): + if self.message: + await self.message.edit(embed=embed or self.generate_embed(), view=self) + + async def join_callback(self, interaction: discord.Interaction): + if interaction.user in self.ctx.player_queue: + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are already in the queue.", None), ephemeral=True) + elif str(interaction.user.id) in DB.column("SELECT A.UserID FROM SixManUsers A INNER JOIN SixManParty B WHERE A.PartyID = B.PartyID AND B.LobbyID IS NOT NULL"): + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are already in a six mans lobby. Failed to join queue.", None), ephemeral=True) + else: + self.ctx.player_queue.add(interaction.user) + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.ctx.player_queue)}/{PARTY_SIZE})", None), ephemeral=True) + await self.update_view() + if ((party := self.ctx.player_queue.get_party())): + await self.update_view() + lobby_id, party_id = await self.ctx.create_party(party) + await self.ctx.start(lobby_id, party_id) + else: + await asyncio.sleep(QUEUE_TIMEOUT * 60) + if interaction.user in self.ctx.player_queue: + self.ctx.player_queue.remove(interaction.user) + await interaction.user.send(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have been removed from the Six Mans queue since a game could not be found in time.", None)) + await self.update_view() + + async def leave_callback(self, interaction: discord.Interaction): + if not interaction.user in self.ctx.player_queue: + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are not in a queue. Click the `Join Queue` button to join the queue.", None), ephemeral=True) + else: + self.ctx.player_queue.remove(interaction.user) + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) + await self.update_view() + + class SixMans(commands.Cog): def __init__(self, bot: discord.Client): self.bot = bot self.logger = logging.getLogger(__name__) - self.queue = list() + self.player_queue = SixMansQueue() self.category = config["SIX_MAN"]["CATEGORY"] async def cog_load(self): + asyncio.create_task(self.create_queue_prompt()) self.logger.info(f"[COG] Loaded {self.__class__.__name__}") - async def check_queue(self): - if len(self.queue) == PARTY_SIZE: - party = self.queue[:PARTY_SIZE] - del self.queue[:PARTY_SIZE] - lobby_id, party_id = await self.create_party(party) - await self.start(lobby_id, party_id) + async def create_queue_prompt(self): + await self.bot.wait_until_ready() + category_channel = self.bot.get_channel(self.category) + if "join-queue" not in [channel.name for channel in category_channel.text_channels]: + channel = await category_channel.create_text_channel("join-queue", overwrites={category_channel.guild.default_role: PermissionOverwrite(send_messages=False, view_channel=True)}) + await asyncio.sleep(3) + message = await channel.send(None, embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"To join/leave the six man queue, click the respective button below.", None)) + view = QueuePrompt(self, message) + await view.update_view() async def generate_lobby_id(self): - highest_lobby_num = max([0] + (lobbies := DB.column("SELECT LobbyID FROM SixManParty WHERE LobbyID IS NOT NULL"))) + highest_lobby_num = max( + [0] + (lobbies := DB.column("SELECT LobbyID FROM SixManParty WHERE LobbyID IS NOT NULL"))) r = list(range(1, highest_lobby_num)) possible_lobby_nums = [i for i in r if i not in lobbies] return highest_lobby_num + 1 if not possible_lobby_nums else possible_lobby_nums.pop(0) - + async def create_party(self, members): lobby_id = await self.generate_lobby_id() lobby_name = f"SixMans #{lobby_id}" @@ -359,65 +511,52 @@ async def create_party(self, members): # Create role lobby_role = await guild.create_role(name=lobby_name, reason=f"{lobby_name} created") - + # Create perms - vc_perms = {lobby_role: PermissionOverwrite(speak=True, connect=True, view_channel=True), guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} - text_perms = {lobby_role: PermissionOverwrite(read_messages=True, send_messages=True), guild.default_role: PermissionOverwrite(read_messages=False)} - + vc_perms = {lobby_role: PermissionOverwrite( + speak=True, connect=True, view_channel=True), guild.default_role: PermissionOverwrite(view_channel=False, connect=False)} + text_perms = {lobby_role: PermissionOverwrite( + read_messages=True, send_messages=False), guild.default_role: PermissionOverwrite(read_messages=False, view_channel=False)} + # Create voice channel voice_channel = await guild.create_voice_channel(name=lobby_name, overwrites=vc_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") - + # Create text channel text_channel = await guild.create_text_channel(name=f"six-mans-{lobby_id}", overwrites=text_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") - + # Send preliminary starting message message = await text_channel.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"SCUFFBOT is creating the six mans lobby. This message will update once complete.", None)) - + lobby_invite_link = str(await voice_channel.create_invite(reason=f"{lobby_name} created")) - DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) + DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", + lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) DB.execute("INSERT INTO SixManParty (LobbyID) VALUES (%s)", lobby_id) party_id = DB.field("SELECT LAST_INSERT_ID()") - + # Invite members for member in members: await member.add_roles(lobby_role, reason=f"{member} added to {lobby_name}") - await member.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have been added into **{lobby_name}**.", None), view=View().add_item(discord.ui.Button(label="Join Six Mans",style=discord.ButtonStyle.link, url=lobby_invite_link))) - DB.execute("INSERT INTO SixManUsers (PartyID, UserID) VALUES (%s, %s)", party_id, member.id) + await member.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have been added into **{lobby_name}**. Failing to join within {LOBBY_TIMEOUT} minutes will drop the match.", None), view=View().add_item(discord.ui.Button(label="Join Six Mans", style=discord.ButtonStyle.link, url=lobby_invite_link))) + DB.execute( + "INSERT INTO SixManUsers (PartyID, UserID) VALUES (%s, %s)", party_id, member.id) return (lobby_id, party_id) - + async def start(self, lobby_id: int, party_id: int): - text_channel = self.bot.get_channel(int(DB.field("SELECT TextChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) + text_channel = self.bot.get_channel(int(DB.field( + "SELECT TextChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) + voice_channel = self.bot.get_channel(int(DB.field( + "SELECT VoiceChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) message = await text_channel.fetch_message(int(DB.field("SELECT MessageID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) view = SixMansPrompt(self.bot, party_id) view.message = message await view.update_view() - - @app_commands.command(name="q", description="Joins the six man queue.") - async def queue(self, interaction: discord.Interaction): - if interaction.user in self.queue: - self.queue.remove(interaction.user) - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) - else: - if str(interaction.user.id) in DB.column("SELECT A.UserID FROM SixManUsers A INNER JOIN SixManParty B WHERE A.PartyID = B.PartyID AND B.LobbyID IS NOT NULL"): - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You are already in a six mans lobby. Failed to join queue.", None), ephemeral=True) - else: - self.queue.append(interaction.user) - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.queue)}/{PARTY_SIZE})", None), ephemeral=True) - await self.check_queue() - - @app_commands.command(name="leave", description="Leaves the six man queue.") - async def leave(self, interaction: discord.Interaction): - if not interaction.user in self.queue: - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You are not in a queue. Type `/q` to join the queue.", None), ephemeral=True) - else: - self.queue.remove(interaction.user) - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) - - @app_commands.command(name="status", description="Returns the number of people in the queue.") - async def status(self, interaction: discord.Interaction): - queue_len = len(self.queue) - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"There {'is' if queue_len == 1 else 'are'} currently {len(self.queue)} {'player' if queue_len == 1 else 'players'} in the queue.", None)) + + # Delete lobby if not all people have joined + await asyncio.sleep(LOBBY_TIMEOUT * 60) + if len(voice_channel.members) != PARTY_SIZE and view.state == SixMansState.PRE_LOBBY: + await view.delete_lobby() + async def setup(bot): await bot.add_cog(SixMans(bot)) @@ -425,23 +564,27 @@ async def setup(bot): class UserDropdown(Select): def __init__(self, users: list[discord.Member], amount: int, reason: str): - options = [discord.SelectOption(label=user.display_name, value=user.id) for user in users] - super().__init__(placeholder=f"Choose {amount} {'member' if amount == 1 else 'members'} to {reason}", min_values=amount, max_values=amount, options=options) + options = [discord.SelectOption( + label=user.display_name, value=user.id) for user in users] + super().__init__(placeholder=f"Choose {amount} {'member' if amount == 1 else 'members'} to {reason}", + min_values=amount, max_values=amount, options=options) async def callback(self, interaction: discord.Interaction): await interaction.response.defer() + class UserDropdownView(View): def __init__(self, ctx: SixMansPrompt, message_interaction: discord.Interaction, users: list[discord.Member], amount: int, reason: str): super().__init__(timeout=None) self.ctx = ctx self.message_interaction = message_interaction self.reason = reason - + self.user_dropdown = UserDropdown(users, amount, reason) self.add_item(self.user_dropdown) - - self.submit_button = discord.ui.Button(label="Submit", style=discord.ButtonStyle.green) + + self.submit_button = discord.ui.Button( + label="Submit", style=discord.ButtonStyle.green) self.submit_button.callback = self.submit_button_callback self.add_item(self.submit_button) @@ -449,19 +592,25 @@ async def submit_button_callback(self, interaction: discord.Interaction): match self.ctx.state: case SixMansState.CHOOSE_CAPTAIN_ONE: for user_id in self.user_dropdown.values: - DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s AND PartyID = %s", user_id, self.ctx.party.party_id) + DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s AND PartyID = %s", + user_id, self.ctx.party.party_id) self.ctx.state = SixMansState.CHOOSE_CAPTAIN_TWO case SixMansState.CHOOSE_CAPTAIN_TWO: for user_id in self.user_dropdown.values: - DB.execute("UPDATE SixManUsers SET Team = 2 WHERE UserID = %s AND PartyID = %s", user_id, self.ctx.party.party_id) + DB.execute("UPDATE SixManUsers SET Team = 2 WHERE UserID = %s AND PartyID = %s", + user_id, self.ctx.party.party_id) # Add last player - last_player_id = DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.ctx.party.party_id) - DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s", last_player_id) + last_player_id = DB.field( + "SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.ctx.party.party_id) + DB.execute( + "UPDATE SixManUsers SET Team = 1 WHERE UserID = %s", last_player_id) self.ctx.state = SixMansState.CHOOSE_1S_PLAYER case SixMansState.CHOOSE_1S_PLAYER: - DB.execute("UPDATE SixManUsers SET isOnesPlayer = 1 WHERE UserID = %s AND PartyID = %s", self.user_dropdown.values[0], self.ctx.party.party_id) + DB.execute("UPDATE SixManUsers SET isOnesPlayer = 1 WHERE UserID = %s AND PartyID = %s", + self.user_dropdown.values[0], self.ctx.party.party_id) if len(DB.rows("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1", self.ctx.party.party_id)) == 2: - DB.execute("UPDATE SixManUsers SET isOnesPlayer = 0 WHERE PartyID = %s AND isOnesPlayer IS NULL", self.ctx.party.party_id) + DB.execute( + "UPDATE SixManUsers SET isOnesPlayer = 0 WHERE PartyID = %s AND isOnesPlayer IS NULL", self.ctx.party.party_id) self.ctx.state = SixMansState.PLAYING await self.message_interaction.edit_original_response(content=f"You have selected {', '.join([interaction.client.get_user(int(user_id)).mention for user_id in self.user_dropdown.values])} to {self.reason}", view=None) await self.ctx.update_view() diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py index 5ee3c68..17e97f2 100644 --- a/src/utils/SixMans.py +++ b/src/utils/SixMans.py @@ -6,8 +6,12 @@ from src.lib.bot import DB PARTY_SIZE = 6 +QUEUE_TIMEOUT = 60 # minutes +LOBBY_TIMEOUT = 5 # minutes + class SixMansState(Enum): + PRE_LOBBY = 0 CHOOSE_CAPTAIN_ONE = 1 CHOOSE_CAPTAIN_TWO = 2 CHOOSE_1S_PLAYER = 3 @@ -15,45 +19,54 @@ class SixMansState(Enum): SCORE_UPLOAD = 5 POST_MATCH = 6 + class SixMansMatchType(Enum): PRE_MATCH = 0 ONE_V_ONE = 1 TWO_V_TWO = 2 THREE_V_THREE = 3 + class SixMansParty(): def __init__(self, bot: discord.Client, party_id: int) -> None: self.bot = bot self.game_id: Union[None, int] = None self.party_id = party_id - self.lobby_id = DB.field("SELECT LobbyID FROM SixManParty WHERE PartyID = %s", self.party_id) + self.lobby_id = DB.field( + "SELECT LobbyID FROM SixManParty WHERE PartyID = %s", self.party_id) self.players = self.get_players() self.captain_one: Union[None, discord.Member] = None self.captain_two: Union[None, discord.Member] = None self.generate_captains() - - self.reported_scores = {self.captain_one.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}, self.captain_two.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}} + + self.reported_scores = {self.captain_one.id: {"1v1": (None, None), "2v2": (None, None), "3v3": ( + None, None)}, self.captain_two.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}} async def get_details(self): return DB.row("SELECT * FROM SixManLobby WHERE LobbyID = %s", self.lobby_id) - + def get_players(self, team: Literal[None, 1, 2] = None): return [self.bot.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party_id, 0 if team == None else team)] - + def generate_captains(self): players = self.get_players() self.captain_one = players.pop(random.randint(0, len(players)-1)) self.captain_two = players.pop(random.randint(0, len(players)-1)) - DB.execute("UPDATE SixManUsers SET Type = 1, Team = 1 WHERE UserID = %s", self.captain_one.id) - DB.execute("UPDATE SixManUsers SET Type = 2, Team = 2 WHERE UserID = %s", self.captain_two.id) + DB.execute( + "UPDATE SixManUsers SET Type = 1, Team = 1 WHERE UserID = %s", self.captain_one.id) + DB.execute( + "UPDATE SixManUsers SET Type = 2, Team = 2 WHERE UserID = %s", self.captain_two.id) def calculate_winner(self) -> Literal[0, 1, 2]: if self.game_id == None: return 0 - data = [0 if x is None else x for x in DB.row("SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.game_id).values()] + data = [0 if x is None else x for x in DB.row( + "SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.game_id).values()] - team_a_wins = sum(data[i] > data[i + 1] for i in range(0, len(data), 2)) - team_b_wins = sum(data[i + 1] > data[i] for i in range(0, len(data), 2)) + team_a_wins = sum(data[i] > data[i + 1] + for i in range(0, len(data), 2)) + team_b_wins = sum(data[i + 1] > data[i] + for i in range(0, len(data), 2)) match (team_a_wins >= 2, team_b_wins >= 2): case (True, False): @@ -62,3 +75,27 @@ def calculate_winner(self) -> Literal[0, 1, 2]: return 2 case (False, False): return 0 + + +class SixMansQueue(): + def __init__(self): + self.queue = list() + + def add(self, player: discord.Member): + self.queue.append(player) + + def remove(self, player: discord.Member): + self.queue.remove(player) + + def get_party(self): + if len(self.queue) == PARTY_SIZE: + party = self.queue[:PARTY_SIZE] + del self.queue[:PARTY_SIZE] + return party + return [] + + def __contains__(self, key): + return key in self.queue + + def __len__(self): + return len(self.queue) From 8a4171e33fe33820ec7fb445aa1172d218324bb8 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Thu, 30 Jan 2025 09:06:15 +1100 Subject: [PATCH 06/26] Made queue prompt buttons persistent --- src/lib/bot/__init__.py | 1 - src/lib/cogs/ErrorHandler.py | 12 ++-- src/lib/cogs/SixMans.py | 124 +++++++++++++++++++---------------- 3 files changed, 74 insertions(+), 63 deletions(-) diff --git a/src/lib/bot/__init__.py b/src/lib/bot/__init__.py index e47f646..d2f8a8e 100644 --- a/src/lib/bot/__init__.py +++ b/src/lib/bot/__init__.py @@ -44,7 +44,6 @@ def setup_logger(self): for handler in logging.getLogger().handlers: if handler.name == "file" and os.path.isfile('logs/latest.log'): handler.doRollover() - logging.getLogger('discord').setLevel(logging.DEBUG) async def load_cog_manager(self): await self.load_extension("src.lib.cogs.Cogs") diff --git a/src/lib/cogs/ErrorHandler.py b/src/lib/cogs/ErrorHandler.py index 50a63a1..a0993bb 100644 --- a/src/lib/cogs/ErrorHandler.py +++ b/src/lib/cogs/ErrorHandler.py @@ -2,6 +2,7 @@ import logging import traceback + class ErrorHandler(commands.Cog): def __init__(self, bot): @@ -11,14 +12,17 @@ def __init__(self, bot): async def cog_load(self): self.logger.info(f"[COG] Loaded {self.__class__.__name__}") - + async def on_app_command_error(self, interaction, error): - embed = self.bot.create_embed("SCUFFBOT ERROR", "An unexpected error has occurred.", 0xFF0000) - embed.add_field(name="ERROR:", value="> {}\n\nIf this error is a regular occurrence, please contact {}. This error has been logged.".format(str(error), self.appinfo.owner.mention), inline=False) + embed = self.bot.create_embed( + "SCUFFBOT ERROR", "An unexpected error has occurred.", 0xFF0000) + embed.add_field(name="ERROR:", value="> {}\n\nIf this error is a regular occurrence, please contact {}. This error has been logged.".format( + str(error), self.appinfo.owner.mention), inline=False) await interaction.response.send_message(embed=embed, ephemeral=True) - + self.logger.error(f"[ERROR] Unhandled Error: {error}") traceback.print_exc() + async def setup(bot): await bot.add_cog(ErrorHandler(bot)) diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index 5465bb2..017eaa9 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -421,65 +421,12 @@ async def on_submit(self, interaction: discord.Interaction): await self.ctx.update_view() -class QueuePrompt(View): - - def __init__(self, ctx, message: discord.Message): - super().__init__(timeout=None) - self.ctx = ctx - self.message = message - - join_button = Button( - label="Join Queue", style=discord.ButtonStyle.green) - join_button.callback = self.join_callback - self.add_item(join_button) - - leave_button = Button( - label="Leave Queue", style=discord.ButtonStyle.grey) - leave_button.callback = self.leave_callback - self.add_item(leave_button) - - def generate_embed(self): - queue_len = len(self.ctx.player_queue) - return self.ctx.bot.create_embed("SCUFFBOT SIX MANS", f"To join/leave the six man queue, click the respective button below.\n\nThere {'is' if queue_len == 1 else 'are'} currently **{queue_len} {'player' if queue_len == 1 else 'players'}** in the queue.", None) - - async def update_view(self, embed=None): - if self.message: - await self.message.edit(embed=embed or self.generate_embed(), view=self) - - async def join_callback(self, interaction: discord.Interaction): - if interaction.user in self.ctx.player_queue: - await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are already in the queue.", None), ephemeral=True) - elif str(interaction.user.id) in DB.column("SELECT A.UserID FROM SixManUsers A INNER JOIN SixManParty B WHERE A.PartyID = B.PartyID AND B.LobbyID IS NOT NULL"): - await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are already in a six mans lobby. Failed to join queue.", None), ephemeral=True) - else: - self.ctx.player_queue.add(interaction.user) - await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.ctx.player_queue)}/{PARTY_SIZE})", None), ephemeral=True) - await self.update_view() - if ((party := self.ctx.player_queue.get_party())): - await self.update_view() - lobby_id, party_id = await self.ctx.create_party(party) - await self.ctx.start(lobby_id, party_id) - else: - await asyncio.sleep(QUEUE_TIMEOUT * 60) - if interaction.user in self.ctx.player_queue: - self.ctx.player_queue.remove(interaction.user) - await interaction.user.send(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have been removed from the Six Mans queue since a game could not be found in time.", None)) - await self.update_view() - - async def leave_callback(self, interaction: discord.Interaction): - if not interaction.user in self.ctx.player_queue: - await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are not in a queue. Click the `Join Queue` button to join the queue.", None), ephemeral=True) - else: - self.ctx.player_queue.remove(interaction.user) - await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) - await self.update_view() - - class SixMans(commands.Cog): def __init__(self, bot: discord.Client): self.bot = bot self.logger = logging.getLogger(__name__) self.player_queue = SixMansQueue() + self.queue_prompt = QueuePrompt(self, None) self.category = config["SIX_MAN"]["CATEGORY"] async def cog_load(self): @@ -489,12 +436,17 @@ async def cog_load(self): async def create_queue_prompt(self): await self.bot.wait_until_ready() category_channel = self.bot.get_channel(self.category) - if "join-queue" not in [channel.name for channel in category_channel.text_channels]: + prompt_channel = next( + filter(lambda c: "join-queue" in c.name, category_channel.text_channels), None) + if prompt_channel == None: channel = await category_channel.create_text_channel("join-queue", overwrites={category_channel.guild.default_role: PermissionOverwrite(send_messages=False, view_channel=True)}) await asyncio.sleep(3) message = await channel.send(None, embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"To join/leave the six man queue, click the respective button below.", None)) - view = QueuePrompt(self, message) - await view.update_view() + self.queue_prompt = QueuePrompt(self, message) + else: + message = next(iter([message async for message in prompt_channel.history() if message.author == self.bot.user])) + self.queue_prompt.message = message + await self.queue_prompt.update_view() async def generate_lobby_id(self): highest_lobby_num = max( @@ -559,7 +511,63 @@ async def start(self, lobby_id: int, party_id: int): async def setup(bot): - await bot.add_cog(SixMans(bot)) + ctx = SixMans(bot) + bot.add_view(ctx.queue_prompt) + await bot.add_cog(ctx) + + +class QueuePrompt(View): + + def __init__(self, ctx: SixMans, message: discord.Message): + super().__init__(timeout=None) + self.ctx = ctx + self.message = message + + join_button = Button( + label="Join Queue", style=discord.ButtonStyle.green, custom_id="queue_prompt_join") + join_button.callback = self.join_callback + self.add_item(join_button) + + leave_button = Button( + label="Leave Queue", style=discord.ButtonStyle.grey, custom_id="queue_prompt_leave") + leave_button.callback = self.leave_callback + self.add_item(leave_button) + + def generate_embed(self): + queue_len = len(self.ctx.player_queue) + return self.ctx.bot.create_embed("SCUFFBOT SIX MANS", f"To join/leave the six man queue, click the respective button below.\n\nThere {'is' if queue_len == 1 else 'are'} currently **{queue_len} {'player' if queue_len == 1 else 'players'}** in the queue.", None) + + async def update_view(self, embed=None): + if self.message: + await self.message.edit(embed=embed or self.generate_embed(), view=self) + + async def join_callback(self, interaction: discord.Interaction): + if interaction.user in self.ctx.player_queue: + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are already in the queue.", None), ephemeral=True) + elif str(interaction.user.id) in DB.column("SELECT A.UserID FROM SixManUsers A INNER JOIN SixManParty B WHERE A.PartyID = B.PartyID AND B.LobbyID IS NOT NULL"): + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are already in a six mans lobby. Failed to join queue.", None), ephemeral=True) + else: + self.ctx.player_queue.add(interaction.user) + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.ctx.player_queue)}/{PARTY_SIZE})", None), ephemeral=True) + await self.update_view() + if ((party := self.ctx.player_queue.get_party())): + await self.update_view() + lobby_id, party_id = await self.ctx.create_party(party) + await self.ctx.start(lobby_id, party_id) + else: + await asyncio.sleep(QUEUE_TIMEOUT * 60) + if interaction.user in self.ctx.player_queue: + self.ctx.player_queue.remove(interaction.user) + await interaction.user.send(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have been removed from the Six Mans queue since a game could not be found in time.", None)) + await self.update_view() + + async def leave_callback(self, interaction: discord.Interaction): + if not interaction.user in self.ctx.player_queue: + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are not in a queue. Click the `Join Queue` button to join the queue.", None), ephemeral=True) + else: + self.ctx.player_queue.remove(interaction.user) + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) + await self.update_view() class UserDropdown(Select): From f1e83f6d002ec8b91e18089123762c080ab381d0 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Sun, 2 Jun 2024 11:15:22 +1000 Subject: [PATCH 07/26] =?UTF-8?q?=F0=9F=94=A7=20Update=20Kube=20config=20f?= =?UTF-8?q?or=20SixMan=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding and updating necessary config for SixMan deployment. --- .env.local | 4 + .github/workflows/deploy.yaml | 8 +- README.md | 2 + db/.env.template | 4 + db/data/init.sql | 59 +++++ docker-compose.yaml | 32 +++ infra/values.yaml | 32 ++- requirements.txt | Bin 540 -> 602 bytes src/lib/bot/__init__.py | 14 +- src/lib/cogs/Cogs.py | 5 +- src/lib/cogs/SixMans.py | 467 ++++++++++++++++++++++++++++++++++ src/lib/db/__init__.py | 46 ++++ src/utils/SixMans.py | 64 +++++ 13 files changed, 727 insertions(+), 10 deletions(-) create mode 100644 db/.env.template create mode 100644 db/data/init.sql create mode 100644 src/lib/cogs/SixMans.py create mode 100644 src/lib/db/__init__.py create mode 100644 src/utils/SixMans.py diff --git a/.env.local b/.env.local index bf82452..a76aa03 100644 --- a/.env.local +++ b/.env.local @@ -1,2 +1,6 @@ BOT_TOKEN=/secrets/bot-token CONFIG_FILE=/config/config.yaml.local +DB_DATABASE=SCUFFBOT +DB_HOST=db +DB_PASSWORD=.local-secrets/db-password +DB_USER=SCUFFBOT diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 76bf7eb..d3ebd3c 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -69,8 +69,6 @@ jobs: steps: - name: Checkout infrastructure config uses: actions/checkout@v4 - with: - sparse-checkout: infra - name: Login to Docker Hub uses: docker/login-action@v3 @@ -96,11 +94,13 @@ jobs: ENVIRONMENT: ${{ needs.build.outputs.environment }} REPO_NAME: ${{ needs.build.outputs.repo-name }} COMMIT_SHA: ${{ needs.build.outputs.commit-sha }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_ROOT_PASSWORD: ${{ secrets.DB_ROOT_PASSWORD }} DOCKERHUB_USERNAME: ${{ secrets.ORG_DOCKERHUB_USERNAME }} DOCKERHUB_REPO: ${{ secrets.ORG_DOCKERHUB_REPO }} BOT_TOKEN: ${{ needs.build.outputs.environment == 'prod' && secrets.BOT_TOKEN_PROD || secrets.BOT_TOKEN_STAGING }} run: | cat infra/values.yaml | envsubst | \ - helm upgrade --install "$REPO_NAME" chartmuseum/generic-app --version 0.1.1 \ - -f - --set-file scuffbotConfig=config.yaml \ + helm upgrade --install "$REPO_NAME" chartmuseum/discord-bot --version 0.1.0 \ + -f - --set-file scuffbotConfig=config.yaml --set-file sqlInitFile=db/data/init.sql \ --namespace="$REPO_NAME-$ENVIRONMENT" --create-namespace --atomic --timeout=1m --cleanup-on-fail diff --git a/README.md b/README.md index 22756d9..b50d506 100644 --- a/README.md +++ b/README.md @@ -12,5 +12,7 @@ If you would like to run locally: | Filename | Description | |--------------------|------------------------------------| | `bot-token` | Token for Discord bot | + | `db-password` | Default password for database | + | `db-root-password` | Root password for the database | 4. Copy [/db/.env.template](./db/.env.template) to `db/.env`, and adjust values as necessary. 5. Run the bot with `docker compose up -d --build`. diff --git a/db/.env.template b/db/.env.template new file mode 100644 index 0000000..3561728 --- /dev/null +++ b/db/.env.template @@ -0,0 +1,4 @@ +MYSQL_DATABASE=SCUFFBOT +MYSQL_USER=SCUFFBOT +MYSQL_PASSWORD=aStrongPassword +MYSQL_ROOT_PASSWORD=anotherStrongPassword diff --git a/db/data/init.sql b/db/data/init.sql new file mode 100644 index 0000000..fe11b6d --- /dev/null +++ b/db/data/init.sql @@ -0,0 +1,59 @@ +CREATE TABLE + `SixManParty` ( + `PartyID` INTEGER NOT NULL AUTO_INCREMENT, + `LobbyID` INTEGER DEFAULT NULL, + `GameID` INTEGER DEFAULT NULL, + PRIMARY KEY (`PartyID`) + ); + +CREATE TABLE + `SixManLobby` ( + `LobbyID` INTEGER NOT NULL, + `MessageID` TEXT NOT NULL, + `VoiceChannelID` TEXT NOT NULL, + `TextChannelID` TEXT NOT NULL, + `RoleID` TEXT NOT NULL, + `VoiceChannelA` VARCHAR(255) DEFAULT NULL UNIQUE, + `VoiceChannelB` VARCHAR(255) DEFAULT NULL UNIQUE, + PRIMARY KEY (`LobbyID`) + ); + +CREATE TABLE + `SixManGames` ( + `GameID` INTEGER NOT NULL AUTO_INCREMENT, + `Timestamp` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `1v1_A` INTEGER DEFAULT NULL, + `1v1_B` INTEGER DEFAULT NULL, + `2v2_A` INTEGER DEFAULT NULL, + `2v2_B` INTEGER DEFAULT NULL, + `3v3_A` INTEGER DEFAULT NULL, + `3v3_B` INTEGER DEFAULT NULL, + PRIMARY KEY (`GameID`) + ); + +CREATE TABLE + `SixManUsers` ( + `PartyID` INTEGER NOT NULL, + `UserID` VARCHAR(255) NOT NULL, + `Type` INTEGER NOT NULL DEFAULT 0, + `Team` INTEGER NOT NULL DEFAULT 0, + `isOnesPlayer` INTEGER DEFAULT NULL, + PRIMARY KEY (`PartyID`, `UserID`) + ); + +ALTER TABLE `SixManParty` ADD CONSTRAINT `FK_LobbyID_SixManParty` FOREIGN KEY (`LobbyID`) REFERENCES `SixManLobby` (`LobbyID`) ON DELETE SET NULL, +ADD CONSTRAINT `FK_GameID` FOREIGN KEY (`GameID`) REFERENCES `SixManGames` (`GameID`) ON DELETE SET NULL; + +ALTER TABLE `SixManUsers` ADD CONSTRAINT `FK_PartyID_SixManUsers` FOREIGN KEY (`PartyID`) REFERENCES `SixManParty` (`PartyID`) ON DELETE CASCADE; + +DELIMITER $$ + +CREATE TRIGGER DeletePartyCascade +AFTER DELETE ON SixManParty +FOR EACH ROW +BEGIN + DELETE FROM SixManLobby WHERE LobbyID = OLD.LobbyID; + DELETE FROM SixManGames WHERE GameID = OLD.GameID; +END $$ + +DELIMITER ; diff --git a/docker-compose.yaml b/docker-compose.yaml index d35617c..bf210cc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,5 +6,37 @@ services: volumes: - ./config.yaml:/app/config.yaml.local - .local-secrets:/app/.local-secrets + depends_on: + db: + condition: service_healthy env_file: - ./.env.local + networks: + - backend + # MySQL Server + db: + image: "mysql/mysql-server:8.0" + restart: unless-stopped + env_file: + - ./db/.env + volumes: + - db_data:/var/lib/mysql + - ./db/data:/docker-entrypoint-initdb.d/ + networks: + - backend + # phpmyadmin + phpmyadmin: + depends_on: + - db + image: phpmyadmin + restart: always + ports: + - "8080:80" + environment: + PMA_HOST: db + networks: + - backend +volumes: + db_data: +networks: + backend: diff --git a/infra/values.yaml b/infra/values.yaml index 65fcf5d..81de687 100644 --- a/infra/values.yaml +++ b/infra/values.yaml @@ -1,9 +1,7 @@ # TODO: Replace schema link below to permanent location # yaml-language-server: $schema=../../infra-helm-charts/charts/generic-app/values.schema.json environment: "${ENVIRONMENT}" - version: "${COMMIT_SHA}" - deployments: - name: "scuffbot" image: "${DOCKERHUB_USERNAME}/${DOCKERHUB_REPO}:${REPO_NAME}-${ENVIRONMENT}" @@ -12,5 +10,35 @@ deployments: environment: BOT_TOKEN: "/secrets/bot-token" CONFIG_FILE: "/config/config.yaml" + DB_DATABASE: SCUFFBOT + DB_HOST: scuffbot-db + DB_PASSWORD: /secrets/db-password + DB_USER: SCUFFBOT secrets: bot-token: "${BOT_TOKEN}" + db-password: "${DB_PASSWORD}" + - name: "scuffbot-db" + image: "mysql/mysql-server:8.0" + config: + fileName: "init.sql" + fileVariable: sqlInitFile + mountPath: /docker-entrypoint-initdb.d/ + containerPort: 3306 + environment: + MYSQL_DATABASE: SCUFFBOT + MYSQL_USER: SCUFFBOT + MYSQL_PASSWORD: /secrets/db-password + MYSQL_ROOT_PASSWORD: /secrets/db-root-password + readinessProbe: + tcpSocket: + port: 3306 + secrets: + db-password: "${DB_PASSWORD}" + db-root-password: "${DB_ROOT_PASSWORD}" + service: + type: ClusterIP + port: 3306 + volumes: + - name: "scuffbot-db" + mountPath: "/var/lib/mysql" + size: "1Gi" diff --git a/requirements.txt b/requirements.txt index 47d06e429fcf5302d4c775597085d93af8a4f194..90ff5067f225028fd93d2a230eac6de98f658c75 100644 GIT binary patch delta 69 zcmbQka*Jg{38Q5$LnT8oLm@*BgDyidLq0 NV=x3_gUR`fYXLa*4*CE9 delta 11 Scmcb`GKXbD3FG8Vj4J>e#src8 diff --git a/src/lib/bot/__init__.py b/src/lib/bot/__init__.py index 017b4f1..8fef474 100644 --- a/src/lib/bot/__init__.py +++ b/src/lib/bot/__init__.py @@ -6,7 +6,9 @@ import logging import discord import yaml +import sys import os +from ..db import DB as SCUFF_DB env_file = find_dotenv(".env.local") load_dotenv(env_file) @@ -14,6 +16,8 @@ with open(os.environ["CONFIG_FILE"], "r", encoding="utf-8") as f: config = yaml.safe_load(f) +DB = SCUFF_DB() + class SCUFFBOT(commands.Bot): def __init__(self, is_dev): @@ -23,8 +27,14 @@ def __init__(self, is_dev): async def setup_hook(self): self.setup_logger() - await self.load_cog_manager() - + self.DB = DB + try: + self.DB.connect() + except Exception as e: + self.logger.error(f"Failed to connect to the database: {e}") + sys.exit(1) + await self.load_cog_manager() + def setup_logger(self): with open("./logging.json") as f: logging.config.dictConfig(json.loads(f.read())) diff --git a/src/lib/cogs/Cogs.py b/src/lib/cogs/Cogs.py index 7d04a92..3428f9b 100644 --- a/src/lib/cogs/Cogs.py +++ b/src/lib/cogs/Cogs.py @@ -8,6 +8,7 @@ import os import traceback + class Cogs(commands.Cog): def __init__(self, bot): @@ -71,8 +72,8 @@ async def load_cogs(self): async def cog_load(self): self.logger.info(f"[COG] Loaded {self.__class__.__name__}") - CogGroup = app_commands.Group( - name="cog", description="Manages SCUFFBOT cogs.", guild_ids=[1165195575013163038, 422983658257907732]) + CogGroup = app_commands.Group(name="cog", description="Manages SCUFFBOT cogs.", guild_ids=[ + 1165195575013163038, 422983658257907732]) @CogGroup.command(name="list", description="Lists all cog statuses.") @app_commands.checks.has_permissions(manage_guild=True) diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py new file mode 100644 index 0000000..c3fd835 --- /dev/null +++ b/src/lib/cogs/SixMans.py @@ -0,0 +1,467 @@ +from discord.ext import commands +from discord.ui import Button, View, Select, Modal, TextInput +from discord import app_commands, PermissionOverwrite, Object +from src.lib.bot import config, DB +from typing import Any, Callable, Union +import discord +import logging + +from src.utils.SixMans import PARTY_SIZE, SixMansState, SixMansMatchType, SixMansParty + +class SixMansPrompt(View): + def __init__(self, bot: discord.Client, party_id: int): + super().__init__(timeout=None) + self.bot = bot + self.message: Union[None | discord.Message] = None + + self.state = SixMansState.CHOOSE_CAPTAIN_ONE + self.game = SixMansMatchType.PRE_MATCH + + self.party = SixMansParty(bot, party_id) + + async def interaction_check(self, interaction: discord.Interaction): + if "custom_id" in interaction.data and interaction.data["custom_id"] == "team_comp": return True + match self.state: + case SixMansState.CHOOSE_CAPTAIN_ONE: + if interaction.user and interaction.user.id != self.party.captain_one.id: + await interaction.response.send_message(f"Only {self.party.captain_one.mention} can do this.", ephemeral=True) + return interaction.user and interaction.user.id == self.party.captain_one.id + case SixMansState.CHOOSE_CAPTAIN_TWO: + if interaction.user and interaction.user.id != self.party.captain_two.id: + await interaction.response.send_message(f"Only {self.party.captain_two.mention} can do this.", ephemeral=True) + return interaction.user and interaction.user.id == self.party.captain_two.id + case SixMansState.CHOOSE_1S_PLAYER | SixMansState.PLAYING | SixMansState.SCORE_UPLOAD | SixMansState.POST_MATCH: + if interaction.user and interaction.user.id not in [self.party.captain_one.id, self.party.captain_two.id]: + await interaction.response.send_message(f"Only {self.party.captain_one.mention} or {self.party.captain_two.mention} can do this.", ephemeral=True) + return interaction.user and interaction.user.id in [self.party.captain_one.id, self.party.captain_two.id] + + async def delete_lobby(self): + lobby_details = await self.party.get_details() + lobby_name = f"SixMans #{self.party.lobby_id}" + + guild = self.bot.get_channel(int(lobby_details["VoiceChannelID"])).guild + await (guild.get_role(int(lobby_details["RoleID"]))).delete(reason=f"{lobby_name} finished/cancelled") + await (self.bot.get_channel(int(lobby_details["VoiceChannelID"]))).delete(reason=f"{lobby_name} finished/cancelled") + await (self.bot.get_channel(int(lobby_details["TextChannelID"]))).delete(reason=f"{lobby_name} finished/cancelled") + + if lobby_details["VoiceChannelA"]: + await (self.bot.get_channel(int(lobby_details["VoiceChannelA"]))).delete(reason=f"{lobby_name} finished/cancelled") + if lobby_details["VoiceChannelB"]: + await (self.bot.get_channel(int(lobby_details["VoiceChannelB"]))).delete(reason=f"{lobby_name} finished/cancelled") + DB.execute("DELETE FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id) + + def get_match_type(self): + match self.game: + case SixMansMatchType.ONE_V_ONE: + match_type = "1v1" + case SixMansMatchType.TWO_V_TWO: + match_type = "2v2" + case SixMansMatchType.THREE_V_THREE: + match_type = "3v3" + return match_type + + async def create_break_out_rooms(self): + lobby_a = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_one.display_name}" + lobby_b = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_two.display_name}" + + team_one_players = self.party.get_players(1) + team_two_players = self.party.get_players(2) + + guild: discord.Guild = self.message.guild + voice_channel_a_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True,connect=True,view_channel=True)), team_one_players))) | {guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + voice_channel_b_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True,connect=True,view_channel=True)), team_two_players))) | {guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + + voice_channel_a = await guild.create_voice_channel(name=lobby_a, overwrites=voice_channel_a_perms, category=self.message.channel.category, reason=f"{lobby_a} created") + voice_channel_b = await guild.create_voice_channel(name=lobby_b, overwrites=voice_channel_b_perms, category=self.message.channel.category, reason=f"{lobby_b} created") + + DB.execute("UPDATE SixManLobby SET VoiceChannelA = %s, VoiceChannelB = %s WHERE LobbyID = %s", voice_channel_a.id, voice_channel_b.id, self.party.lobby_id) + + # Move members + for member in guild.get_channel(int(DB.field("SELECT VoiceChannelID FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id))).members: + if member in team_one_players: + await member.move_to(voice_channel_a) + else: + await member.move_to(voice_channel_b) + + def generate_flag_str(self, member: discord.Member): + data = DB.row("SELECT Type, isOnesPlayer FROM SixManUsers WHERE PartyID = %s AND UserID = %s", self.party.party_id, member.id) + flags = [] + match data["Type"]: + case 1 | 2: + flags.append("CAPTAIN") + match data["isOnesPlayer"]: + case 1: + flags.append("1s") + case 0: + flags.append("2s") + case _: pass + flags.append("3s") + return ", ".join(flags) + + def generate_match_summary(self) -> str: + scores = [0 if x is None else x for x in DB.row("SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.party.game_id).values()] + team_a = ['W' if x else '-' for x in [scores[i] > scores[i + 1] for i in range(0, len(scores), 2)]] + team_b = ['W' if x else '-' for x in [scores[i+1] > scores[i] for i in range(0, len(scores), 2)]] + + return (f"""``` +| | 1v1 | 2v2 | 3v3 | +|--------|-----|-----|-----| +| Team A | {team_a[0]} | {team_a[1]} | {team_a[2]} | +| Team B | {team_b[0]} | {team_b[1]} | {team_b[2]} |```""") + + def generate_match_composition(self) -> str: + players = DB.rows("SELECT UserID, Type, Team, isOnesPlayer FROM SixManUsers WHERE PartyID = %s", self.party.party_id) + + ones_a = [row["UserID"] for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 1] + ones_b = [row["UserID"] for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 2] + + twos_a = [row["UserID"] for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 1] + twos_b = [row["UserID"] for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 2] + + threes_a = [row["UserID"] for row in players if row["Team"] == 1] + threes_b = [row["UserID"] for row in players if row["Team"] == 2] + + return (f"""### 1v1 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.ONE_V_ONE else ''} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_a]) if ones_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_b]) if ones_b else 'TBD'} +### 2v2 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.TWO_V_TWO else ''} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_a]) if twos_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_b]) if twos_b else 'TBD'} +### 3v3 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.THREE_V_THREE else ''} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_a]) if threes_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_b]) if threes_b else 'TBD'} +""") + + def generate_embed(self): + team_one_players = self.party.get_players(1) + team_two_players = self.party.get_players(2) + team_one_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] + team_two_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] + match self.state: + case SixMansState.CHOOSE_CAPTAIN_ONE: + description = f"Hello! Welcome to {self.bot.user.mention} Six Mans!\n\nTo begin, {self.party.captain_one.mention} needs to select **ONE** player to join their team.\n\n" + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field(name="Team A", value="\n".join(team_one_str)) + embed.add_field(name="Team B", value="\n".join(team_two_str)) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed + case SixMansState.CHOOSE_CAPTAIN_TWO: + description = f"{self.party.captain_two.mention} now needs to select **TWO** players to join their team.\n\n" + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field(name="Team A", value="\n".join(team_one_str)) + embed.add_field(name="Team B", value="\n".join(team_two_str)) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed + case SixMansState.CHOOSE_1S_PLAYER: + ones_players = (DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 1", self.party.party_id), DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 2", self.party.party_id)) + if ones_players[0] == None and ones_players[1] == None: + waiting_message = "both team captains to nominate a player.**" + elif ones_players[0] == None and ones_players[1] != None: + waiting_message = f"{self.party.captain_one.mention} to nominate a player.**" + else: + waiting_message = f"{self.party.captain_two.mention} to nominate a player.**" + description = f"Perfect! We now have our teams sorted. We will start off with the 1v1 match.\n\nTo begin, each team captain needs to nominate a player from their own team to play their 1s match. You can do this by clicking the **Nominate player** button below.\n\n**Currently waiting on {waiting_message}" + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field(name="Team A", value="\n".join(team_one_str)) + embed.add_field(name="Team B", value="\n".join(team_two_str)) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed + case SixMansState.PLAYING: + match self.game: + case SixMansMatchType.PRE_MATCH: + description = f"Now that we have our 1s players sorted, we are ready to get the ball rolling... *pun intended :D*\n\nAmongst yourselves, please nominate a player to host a private match. Whether you create separate 1v1, 2v2, and 3v3 matches or create a single 3v3 match and re-use it for all matches is entirely up to you.\n\nFrom this point onwards, if you would like to see the entire team composition, click the **View team composition** button below.\n\nThe next screen will show you a breakdown of the matches with specific team compositions for each match.\n\nWhen you are ready to move on, click the **Break out** button below and you will be moved automatically into separate channels. May the best team win!" + case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: + match_type = self.get_match_type() + description = f"You are now playing the {match_type} match. **Don't forget to come back here before your next game starts.**\n\n{self.generate_match_summary()}\n{self.generate_match_composition()}\n\nOnce you have finished your {match_type} match, click on the **Finish {match_type}** button below. Best of luck!" + case SixMansState.SCORE_UPLOAD: + match_type = self.get_match_type() + + team_a_reported_score = self.party.reported_scores[self.party.captain_one.id][match_type] + team_b_reported_score = self.party.reported_scores[self.party.captain_two.id][match_type] + + if team_a_reported_score == (None, None) and team_b_reported_score == (None, None): + waiting_message = "both team captains to upload the match score.**" + elif team_a_reported_score == (None, None) and team_b_reported_score != (None, None): + waiting_message = f"{self.party.captain_one.mention} to upload the match score.**" + elif team_a_reported_score != (None, None) and team_b_reported_score == (None, None): + waiting_message = f"{self.party.captain_two.mention} to upload the match score.**" + else: + waiting_message = f"a resolution to a discrepancy between reported match scores.**" + + description = f"Now that the {match_type} match is complete, both team captains must upload the score of the match.\n\nThe score will not be registered if there is a score discrepancy between both teams.\n\n**Currently waiting on {waiting_message}" + case SixMansState.POST_MATCH: + match self.party.calculate_winner(): + case 1: + winner = "A" + case 2: + winner = "B" + description = f"### Congratulations Team {winner}!\nYou have won the six mans scrims!\n\nPress the **End Game** button below when you are done. Thanks for playing!" + case _: + description = "Uh oh! Something has gone wrong." + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed + + def add_button(self, label: str, style: discord.ButtonStyle, callback: Callable[[discord.Interaction], Any], **kwargs): + button = Button(label=label, style=style, row=1, **kwargs) + button.callback = callback + self.add_item(button) + + def update_options(self): + self.clear_items() + match self.state: + case SixMansState.CHOOSE_CAPTAIN_ONE | SixMansState.CHOOSE_CAPTAIN_TWO: + self.add_button("Choose Player(s)", discord.ButtonStyle.blurple, self.choose_button_callback) + self.add_button("Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) + case SixMansState.CHOOSE_1S_PLAYER: + self.add_button("Nominate Player", discord.ButtonStyle.blurple, self.choose_button_callback) + self.add_button("Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) + case SixMansState.PLAYING: + match self.game: + case SixMansMatchType.PRE_MATCH: + self.add_button("Break Out", discord.ButtonStyle.blurple, self.break_out_button_callback) + self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: + match_label = self.get_match_type() + self.add_button(f"Finish {match_label}", discord.ButtonStyle.blurple, self.finish_button_callback) + self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + self.add_button(f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) + case SixMansState.SCORE_UPLOAD: + self.add_button("Upload Scores", discord.ButtonStyle.blurple, self.upload_scores_callback) + self.add_button("View team composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + case SixMansState.POST_MATCH: + self.add_button("End Game", discord.ButtonStyle.red, self.cancel_button_callback) + + async def update_view(self, embed=None): + self.update_options() + if self.message: + await self.message.edit(embed=embed or self.generate_embed(), view=self) + + async def cancel_button_callback(self, _: discord.Interaction): + await self.delete_lobby() + + async def choose_button_callback(self, interaction: discord.Interaction): + match self.state: + case SixMansState.CHOOSE_CAPTAIN_ONE | SixMansState.CHOOSE_CAPTAIN_TWO: + users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.party.party_id)] + reason = "join your team." + case SixMansState.CHOOSE_1S_PLAYER: + users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party.party_id, DB.field("SELECT Team FROM SixManUsers WHERE UserID = %s AND PartyID = %s", interaction.user.id, self.party.party_id))] + reason = "play your 1s match." + view = UserDropdownView(self, message_interaction=interaction, users=users, amount=1 if self.state in [SixMansState.CHOOSE_CAPTAIN_ONE, SixMansState.CHOOSE_1S_PLAYER] else 2 if self.state == SixMansState.CHOOSE_CAPTAIN_TWO else 0, reason=reason) + await interaction.response.send_message(view=view, ephemeral=True) + + async def break_out_button_callback(self, interaction: discord.Interaction): + await interaction.response.defer() + await self.create_break_out_rooms() + self.game = SixMansMatchType.ONE_V_ONE + DB.execute("INSERT INTO SixManGames () VALUES ()") + self.party.game_id = DB.field("SELECT LAST_INSERT_ID()") + DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", self.party.game_id, self.party.party_id) + await self.update_view() + + async def surrender_button_callback(self, interaction: discord.Interaction): + match_type = self.get_match_type() + DB.execute(f"UPDATE SixManGames SET {match_type}_{'A' if interaction.user.id == self.party.captain_one.id else 'B'} = %s WHERE GameID = %s", -1, self.party.game_id) + if self.party.calculate_winner() == 0: + self.game = SixMansMatchType.THREE_V_THREE if self.game == SixMansMatchType.TWO_V_TWO else SixMansMatchType.TWO_V_TWO + self.state = SixMansState.PLAYING + else: + self.state = SixMansState.POST_MATCH + await self.update_view() + return await interaction.response.send_message(f"You have surrendered the {match_type} match.", ephemeral=True) + + async def upload_scores_callback(self, interaction: discord.Interaction): + match_type = self.get_match_type() + await interaction.response.send_modal(ScoreUpload(self, match_type)) + + async def finish_button_callback(self, interaction: discord.Interaction): + await interaction.response.defer() + self.state = SixMansState.SCORE_UPLOAD + await self.update_view() + + async def team_composition_callback(self, interaction: discord.Interaction): + team_one_players = self.party.get_players(1) + team_two_players = self.party.get_players(2) + team_one_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] + team_two_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] + embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", "", None) + embed.add_field(name="Team A", value="\n".join(team_one_str)) + embed.add_field(name="Team B", value="\n".join(team_two_str)) + embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + await interaction.response.send_message(embed=embed, ephemeral=True) + +class ScoreUpload(Modal): + score_a = TextInput(label='Team A Score') + score_b = TextInput(label='Team B Score') + + def __init__(self, ctx: SixMansPrompt, title: str) -> None: + self.ctx = ctx + super().__init__(title=title) + + async def on_submit(self, interaction: discord.Interaction): + await interaction.response.defer() + match_type = self.ctx.get_match_type() + + if self.score_a.value == self.score_b.value: + return await interaction.followup.send("The team's score cannot be identical. Please re-input your values.", ephemeral=True) + elif (not self.score_a.value.isnumeric()) or (not self.score_b.value.isnumeric()): + return await interaction.followup.send("One or more of your scores is not valid. Please ensure they are non-negative numbers.", ephemeral=True) + else: + self.ctx.party.reported_scores[interaction.user.id][match_type] = (int(self.score_a.value), int(self.score_b.value)) + + # Check if both scores are present and compare + team_a_reported_score = self.ctx.party.reported_scores[self.ctx.party.captain_one.id][match_type] + team_b_reported_score = self.ctx.party.reported_scores[self.ctx.party.captain_two.id][match_type] + + if team_a_reported_score == team_b_reported_score: + # Both scores set and equal + DB.execute(f"UPDATE SixManGames SET {match_type}_A = %s, {match_type}_B = %s WHERE GameID = %s", team_a_reported_score[0], team_b_reported_score[1], self.ctx.party.game_id) + match self.ctx.game: + case SixMansMatchType.ONE_V_ONE: + self.ctx.game = SixMansMatchType.TWO_V_TWO + self.ctx.state = SixMansState.PLAYING + case SixMansMatchType.TWO_V_TWO: + if self.ctx.party.calculate_winner() == 0: + self.ctx.game = SixMansMatchType.THREE_V_THREE + self.ctx.state = SixMansState.PLAYING + else: + self.ctx.state = SixMansState.POST_MATCH + case SixMansMatchType.THREE_V_THREE: + self.ctx.state = SixMansState.POST_MATCH + await self.ctx.update_view() + +class SixMans(commands.Cog): + def __init__(self, bot: discord.Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.queue = list() + self.category = config["SIX_MAN"]["CATEGORY"] + + async def cog_load(self): + self.logger.info(f"[COG] Loaded {self.__class__.__name__}") + + async def check_queue(self): + if len(self.queue) == PARTY_SIZE: + party = self.queue[:PARTY_SIZE] + del self.queue[:PARTY_SIZE] + lobby_id, party_id = await self.create_party(party) + await self.start(lobby_id, party_id) + + async def generate_lobby_id(self): + highest_lobby_num = max([0] + (lobbies := DB.column("SELECT LobbyID FROM SixManParty WHERE LobbyID IS NOT NULL"))) + r = list(range(1, highest_lobby_num)) + possible_lobby_nums = [i for i in r if i not in lobbies] + return highest_lobby_num + 1 if not possible_lobby_nums else possible_lobby_nums.pop(0) + + async def create_party(self, members): + lobby_id = await self.generate_lobby_id() + lobby_name = f"SixMans #{lobby_id}" + + guild: discord.Guild = members[0].guild + + # Create role + lobby_role = await guild.create_role(name=lobby_name, reason=f"{lobby_name} created") + + # Create perms + vc_perms = {lobby_role: PermissionOverwrite(speak=True, connect=True, view_channel=True), guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + text_perms = {lobby_role: PermissionOverwrite(read_messages=True, send_messages=True), guild.default_role: PermissionOverwrite(read_messages=False)} + + # Create voice channel + voice_channel = await guild.create_voice_channel(name=lobby_name, overwrites=vc_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") + + # Create text channel + text_channel = await guild.create_text_channel(name=f"six-mans-{lobby_id}", overwrites=text_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") + + # Send preliminary starting message + message = await text_channel.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"SCUFFBOT is creating the six mans lobby. This message will update once complete.", None)) + + lobby_invite_link = str(await voice_channel.create_invite(reason=f"{lobby_name} created")) + + DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) + DB.execute("INSERT INTO SixManParty (LobbyID) VALUES (%s)", lobby_id) + party_id = DB.field("SELECT LAST_INSERT_ID()") + + # Invite members + for member in members: + await member.add_roles(lobby_role, reason=f"{member} added to {lobby_name}") + await member.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have been added into **{lobby_name}**.", None), view=View().add_item(discord.ui.Button(label="Join Six Mans",style=discord.ButtonStyle.link, url=lobby_invite_link))) + DB.execute("INSERT INTO SixManUsers (PartyID, UserID) VALUES (%s, %s)", party_id, member.id) + return (lobby_id, party_id) + + async def start(self, lobby_id: int, party_id: int): + text_channel = self.bot.get_channel(int(DB.field("SELECT TextChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) + message = await text_channel.fetch_message(int(DB.field("SELECT MessageID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) + view = SixMansPrompt(self.bot, party_id) + view.message = message + await view.update_view() + + @app_commands.command(name="q", description="Joins the six man queue.") + async def queue(self, interaction: discord.Interaction): + if interaction.user in self.queue: + self.queue.remove(interaction.user) + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) + else: + if str(interaction.user.id) in DB.column("SELECT A.UserID FROM SixManUsers A INNER JOIN SixManParty B WHERE A.PartyID = B.PartyID AND B.LobbyID IS NOT NULL"): + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You are already in a six mans lobby. Failed to join queue.", None), ephemeral=True) + else: + self.queue.append(interaction.user) + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.queue)}/{PARTY_SIZE})", None), ephemeral=True) + await self.check_queue() + + @app_commands.command(name="leave", description="Leaves the six man queue.") + async def leave(self, interaction: discord.Interaction): + if not interaction.user in self.queue: + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You are not in a queue. Type `/q` to join the queue.", None), ephemeral=True) + else: + self.queue.remove(interaction.user) + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) + + @app_commands.command(name="status", description="Returns the number of people in the queue.") + async def status(self, interaction: discord.Interaction): + queue_len = len(self.queue) + await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"There {'is' if queue_len == 1 else 'are'} currently {len(self.queue)} {'player' if queue_len == 1 else 'players'} in the queue.", None)) + +async def setup(bot): + await bot.add_cog(SixMans(bot)) + + +class UserDropdown(Select): + def __init__(self, users: list[discord.Member], amount: int, reason: str): + options = [discord.SelectOption(label=user.display_name, value=user.id) for user in users] + super().__init__(placeholder=f"Choose {amount} {'member' if amount == 1 else 'members'} to {reason}", min_values=amount, max_values=amount, options=options) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer() + +class UserDropdownView(View): + def __init__(self, ctx: SixMansPrompt, message_interaction: discord.Interaction, users: list[discord.Member], amount: int, reason: str): + super().__init__(timeout=None) + self.ctx = ctx + self.message_interaction = message_interaction + self.reason = reason + + self.user_dropdown = UserDropdown(users, amount, reason) + self.add_item(self.user_dropdown) + + self.submit_button = discord.ui.Button(label="Submit", style=discord.ButtonStyle.green) + self.submit_button.callback = self.submit_button_callback + self.add_item(self.submit_button) + + async def submit_button_callback(self, interaction: discord.Interaction): + match self.ctx.state: + case SixMansState.CHOOSE_CAPTAIN_ONE: + for user_id in self.user_dropdown.values: + DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s AND PartyID = %s", user_id, self.ctx.party.party_id) + self.ctx.state = SixMansState.CHOOSE_CAPTAIN_TWO + case SixMansState.CHOOSE_CAPTAIN_TWO: + for user_id in self.user_dropdown.values: + DB.execute("UPDATE SixManUsers SET Team = 2 WHERE UserID = %s AND PartyID = %s", user_id, self.ctx.party.party_id) + # Add last player + last_player_id = DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.ctx.party.party_id) + DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s", last_player_id) + self.ctx.state = SixMansState.CHOOSE_1S_PLAYER + case SixMansState.CHOOSE_1S_PLAYER: + DB.execute("UPDATE SixManUsers SET isOnesPlayer = 1 WHERE UserID = %s AND PartyID = %s", self.user_dropdown.values[0], self.ctx.party.party_id) + if len(DB.rows("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1", self.ctx.party.party_id)) == 2: + DB.execute("UPDATE SixManUsers SET isOnesPlayer = 0 WHERE PartyID = %s AND isOnesPlayer IS NULL", self.ctx.party.party_id) + self.ctx.state = SixMansState.PLAYING + await self.message_interaction.edit_original_response(content=f"You have selected {', '.join([interaction.client.get_user(int(user_id)).mention for user_id in self.user_dropdown.values])} to {self.reason}", view=None) + await self.ctx.update_view() diff --git a/src/lib/db/__init__.py b/src/lib/db/__init__.py new file mode 100644 index 0000000..50ff4bc --- /dev/null +++ b/src/lib/db/__init__.py @@ -0,0 +1,46 @@ +import logging +import mysql.connector as mysql +from mysql.connector import errorcode +from dotenv import find_dotenv, load_dotenv +import os + +env_file = find_dotenv(".env.local") +load_dotenv(env_file) + +class DB: + def __init__(self) -> None: + self.logger = logging.getLogger(__name__) + + def connect(self): + try: + self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv("DB_USER"), password=os.getenv("DB_PASSWORD"), database=os.getenv("DB_DATABASE"), autocommit=True) + except mysql.Error as err: + if err.errno == errorcode.ER_ACCESS_DENIED_ERROR: + self.logger.error("[DB] Database credentials incorrect.") + elif err.errno == errorcode.ER_BAD_DB_ERROR: + self.logger.error("[DB] Database does not exist.") + else: + self.logger.error(f"[DB] {err}") + raise err + else: + self.cursor = self.connection.cursor(dictionary=True) + self.logger.info("[DB] Connection established.") + + def execute(self, command, *values): + self.cursor.execute(command, tuple(values)) + + def field(self, command, *values): + self.cursor.execute(command, tuple(values)) + return None if not (data := self.cursor.fetchone()) else list(data.values())[0] + + def row(self, command, *values): + self.cursor.execute(command, tuple(values)) + return self.cursor.fetchone() + + def rows(self, command, *values): + self.cursor.execute(command, tuple(values)) + return self.cursor.fetchall() + + def column(self, command, *values): + self.cursor.execute(command, tuple(values)) + return [list(row.values())[0] for row in self.cursor.fetchall()] diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py new file mode 100644 index 0000000..5ee3c68 --- /dev/null +++ b/src/utils/SixMans.py @@ -0,0 +1,64 @@ +from enum import Enum +import random +from typing import Literal, Union + +import discord +from src.lib.bot import DB + +PARTY_SIZE = 6 + +class SixMansState(Enum): + CHOOSE_CAPTAIN_ONE = 1 + CHOOSE_CAPTAIN_TWO = 2 + CHOOSE_1S_PLAYER = 3 + PLAYING = 4 + SCORE_UPLOAD = 5 + POST_MATCH = 6 + +class SixMansMatchType(Enum): + PRE_MATCH = 0 + ONE_V_ONE = 1 + TWO_V_TWO = 2 + THREE_V_THREE = 3 + +class SixMansParty(): + def __init__(self, bot: discord.Client, party_id: int) -> None: + self.bot = bot + self.game_id: Union[None, int] = None + self.party_id = party_id + self.lobby_id = DB.field("SELECT LobbyID FROM SixManParty WHERE PartyID = %s", self.party_id) + self.players = self.get_players() + self.captain_one: Union[None, discord.Member] = None + self.captain_two: Union[None, discord.Member] = None + self.generate_captains() + + self.reported_scores = {self.captain_one.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}, self.captain_two.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}} + + async def get_details(self): + return DB.row("SELECT * FROM SixManLobby WHERE LobbyID = %s", self.lobby_id) + + def get_players(self, team: Literal[None, 1, 2] = None): + return [self.bot.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party_id, 0 if team == None else team)] + + def generate_captains(self): + players = self.get_players() + self.captain_one = players.pop(random.randint(0, len(players)-1)) + self.captain_two = players.pop(random.randint(0, len(players)-1)) + DB.execute("UPDATE SixManUsers SET Type = 1, Team = 1 WHERE UserID = %s", self.captain_one.id) + DB.execute("UPDATE SixManUsers SET Type = 2, Team = 2 WHERE UserID = %s", self.captain_two.id) + + def calculate_winner(self) -> Literal[0, 1, 2]: + if self.game_id == None: + return 0 + data = [0 if x is None else x for x in DB.row("SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.game_id).values()] + + team_a_wins = sum(data[i] > data[i + 1] for i in range(0, len(data), 2)) + team_b_wins = sum(data[i + 1] > data[i] for i in range(0, len(data), 2)) + + match (team_a_wins >= 2, team_b_wins >= 2): + case (True, False): + return 1 + case (False, True): + return 2 + case (False, False): + return 0 From a34fa512e0dbff317774163984c90139679c851d Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Fri, 24 Jan 2025 20:09:21 +1100 Subject: [PATCH 08/26] Fixed local deployment + password files not resolving --- .env.local | 2 +- db/.env.template | 4 ++-- docker-compose.yaml | 14 +++++++++++--- infra/values.yaml | 8 ++++---- src/lib/bot/__init__.py | 22 ++++++++++++++-------- src/lib/db/__init__.py | 11 ++++++++--- 6 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.env.local b/.env.local index a76aa03..7136b13 100644 --- a/.env.local +++ b/.env.local @@ -2,5 +2,5 @@ BOT_TOKEN=/secrets/bot-token CONFIG_FILE=/config/config.yaml.local DB_DATABASE=SCUFFBOT DB_HOST=db -DB_PASSWORD=.local-secrets/db-password +DB_PASSWORD_FILE=/secrets/db-password DB_USER=SCUFFBOT diff --git a/db/.env.template b/db/.env.template index 3561728..b857555 100644 --- a/db/.env.template +++ b/db/.env.template @@ -1,4 +1,4 @@ MYSQL_DATABASE=SCUFFBOT MYSQL_USER=SCUFFBOT -MYSQL_PASSWORD=aStrongPassword -MYSQL_ROOT_PASSWORD=anotherStrongPassword +MYSQL_PASSWORD_FILE=/secrets/db-password +MYSQL_ROOT_PASSWORD_FILE=/secrets/db-root-password diff --git a/docker-compose.yaml b/docker-compose.yaml index bf210cc..20a285d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,8 +4,8 @@ services: build: ./ restart: unless-stopped volumes: - - ./config.yaml:/app/config.yaml.local - - .local-secrets:/app/.local-secrets + - ./.local-secrets:/secrets + - ./config.yaml.local:/config/config.yaml.local depends_on: db: condition: service_healthy @@ -15,15 +15,23 @@ services: - backend # MySQL Server db: - image: "mysql/mysql-server:8.0" + image: "mysql:9.2.0" restart: unless-stopped env_file: - ./db/.env volumes: - db_data:/var/lib/mysql - ./db/data:/docker-entrypoint-initdb.d/ + - .local-secrets/db-password:/secrets/db-password + - .local-secrets/db-root-password:/secrets/db-root-password networks: - backend + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"] + interval: 5s + timeout: 3s + retries: 2 + start_period: 0s # phpmyadmin phpmyadmin: depends_on: diff --git a/infra/values.yaml b/infra/values.yaml index 81de687..2434b0c 100644 --- a/infra/values.yaml +++ b/infra/values.yaml @@ -12,13 +12,13 @@ deployments: CONFIG_FILE: "/config/config.yaml" DB_DATABASE: SCUFFBOT DB_HOST: scuffbot-db - DB_PASSWORD: /secrets/db-password + DB_PASSWORD_FILE: /secrets/db-password DB_USER: SCUFFBOT secrets: bot-token: "${BOT_TOKEN}" db-password: "${DB_PASSWORD}" - name: "scuffbot-db" - image: "mysql/mysql-server:8.0" + image: "mysql:9.2.0" config: fileName: "init.sql" fileVariable: sqlInitFile @@ -27,8 +27,8 @@ deployments: environment: MYSQL_DATABASE: SCUFFBOT MYSQL_USER: SCUFFBOT - MYSQL_PASSWORD: /secrets/db-password - MYSQL_ROOT_PASSWORD: /secrets/db-root-password + MYSQL_PASSWORD_FILE: /secrets/db-password + MYSQL_ROOT_PASSWORD_FILE: /secrets/db-root-password readinessProbe: tcpSocket: port: 3306 diff --git a/src/lib/bot/__init__.py b/src/lib/bot/__init__.py index 8fef474..e47f646 100644 --- a/src/lib/bot/__init__.py +++ b/src/lib/bot/__init__.py @@ -18,10 +18,12 @@ DB = SCUFF_DB() + class SCUFFBOT(commands.Bot): def __init__(self, is_dev): - super().__init__(command_prefix="!", owner_id=169402073404669952, intents=discord.Intents.all()) + super().__init__(command_prefix="!", + owner_id=169402073404669952, intents=discord.Intents.all()) self.is_dev = is_dev self.mode = "DEVELOPMENT" if is_dev else "PRODUCTION" @@ -33,8 +35,8 @@ async def setup_hook(self): except Exception as e: self.logger.error(f"Failed to connect to the database: {e}") sys.exit(1) - await self.load_cog_manager() - + await self.load_cog_manager() + def setup_logger(self): with open("./logging.json") as f: logging.config.dictConfig(json.loads(f.read())) @@ -51,14 +53,17 @@ def run(self, bot_token: str): super().run(bot_token, log_handler=None) def create_embed(self, title, description, colour): - embed = discord.Embed(title=None, description=description, colour=colour if colour else 0xDC3145, timestamp=discord.utils.utcnow()) - embed.set_author(name=title if title else None, icon_url=self.avatar_url) + embed = discord.Embed(title=None, description=description, + colour=colour if colour else 0xDC3145, timestamp=discord.utils.utcnow()) + embed.set_author(name=title if title else None, + icon_url=self.avatar_url) return embed # Doesn't work, need to look into @staticmethod def has_permissions(**perms): original = app_commands.checks.has_permissions(**perms) + async def extended_check(interaction): if interaction.guild is None: return False @@ -73,15 +78,16 @@ async def on_ready(self): self.appinfo = await super().application_info() self.avatar_url = self.appinfo.icon.url if self.appinfo.icon is not None else None self.logger.info( - f"Connected on {self.user.name} ({self.mode}) | d.py v{str(discord.__version__)}" + f"Connected on {self.user.name} | d.py v{str(discord.__version__)}" ) async def on_interaction(self, interaction): - self.logger.info(f"[COMMAND] [{interaction.guild} // {interaction.guild.id}] {interaction.user} ({interaction.user.id}) used command {interaction.command.name}") + self.logger.info( + f"[COMMAND] [{interaction.guild} // {interaction.guild.id}] {interaction.user} ({interaction.user.id}) used command {interaction.command.name}") async def on_message(self, message): await self.wait_until_ready() - if(isinstance(message.channel, discord.DMChannel) and message.author.id == self.owner_id): + if (isinstance(message.channel, discord.DMChannel) and message.author.id == self.owner_id): message_components = message.content.lower().split(" ") match message_components[0]: case "sync": diff --git a/src/lib/db/__init__.py b/src/lib/db/__init__.py index 50ff4bc..99e8a67 100644 --- a/src/lib/db/__init__.py +++ b/src/lib/db/__init__.py @@ -7,13 +7,18 @@ env_file = find_dotenv(".env.local") load_dotenv(env_file) + class DB: def __init__(self) -> None: self.logger = logging.getLogger(__name__) def connect(self): + with open(os.getenv("DB_PASSWORD_FILE"), "r", encoding="utf-8") as f: + password = f.read().strip() + print(password, flush=True) try: - self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv("DB_USER"), password=os.getenv("DB_PASSWORD"), database=os.getenv("DB_DATABASE"), autocommit=True) + self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv( + "DB_USER"), password=password, database=os.getenv("DB_DATABASE"), autocommit=True) except mysql.Error as err: if err.errno == errorcode.ER_ACCESS_DENIED_ERROR: self.logger.error("[DB] Database credentials incorrect.") @@ -32,11 +37,11 @@ def execute(self, command, *values): def field(self, command, *values): self.cursor.execute(command, tuple(values)) return None if not (data := self.cursor.fetchone()) else list(data.values())[0] - + def row(self, command, *values): self.cursor.execute(command, tuple(values)) return self.cursor.fetchone() - + def rows(self, command, *values): self.cursor.execute(command, tuple(values)) return self.cursor.fetchall() From c7c32afb2adbff9ae9e5cb82a3d5fd6108b247b4 Mon Sep 17 00:00:00 2001 From: Sam Zheng Date: Fri, 24 Jan 2025 23:22:10 +1300 Subject: [PATCH 09/26] =?UTF-8?q?=F0=9F=94=A7=20Update=20`values.yaml`=20t?= =?UTF-8?q?o=20support=20DB=20statefulset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Helm chart template was recently updated to support stateful sets. Updating the `values.yaml` to move the DB to using a stateful set, which makes more sense for a database and should hopefully resolve the strange issues that ScuffBot has been experiencing in production. --- .github/workflows/deploy.yaml | 2 +- infra/values.yaml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index d3ebd3c..3304c10 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -101,6 +101,6 @@ jobs: BOT_TOKEN: ${{ needs.build.outputs.environment == 'prod' && secrets.BOT_TOKEN_PROD || secrets.BOT_TOKEN_STAGING }} run: | cat infra/values.yaml | envsubst | \ - helm upgrade --install "$REPO_NAME" chartmuseum/discord-bot --version 0.1.0 \ + helm upgrade --install "$REPO_NAME" chartmuseum/discord-bot --version 0.2.0 \ -f - --set-file scuffbotConfig=config.yaml --set-file sqlInitFile=db/data/init.sql \ --namespace="$REPO_NAME-$ENVIRONMENT" --create-namespace --atomic --timeout=1m --cleanup-on-fail diff --git a/infra/values.yaml b/infra/values.yaml index 2434b0c..5488f93 100644 --- a/infra/values.yaml +++ b/infra/values.yaml @@ -17,6 +17,8 @@ deployments: secrets: bot-token: "${BOT_TOKEN}" db-password: "${DB_PASSWORD}" + +statefulSets: - name: "scuffbot-db" image: "mysql:9.2.0" config: @@ -37,6 +39,7 @@ deployments: db-root-password: "${DB_ROOT_PASSWORD}" service: type: ClusterIP + headless: true port: 3306 volumes: - name: "scuffbot-db" From 8e5b9f2ca40b74abf9af02c26ea87b885376a313 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Fri, 24 Jan 2025 21:55:41 +1100 Subject: [PATCH 10/26] Remove extraneous print --- src/lib/db/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/db/__init__.py b/src/lib/db/__init__.py index 99e8a67..fb40caf 100644 --- a/src/lib/db/__init__.py +++ b/src/lib/db/__init__.py @@ -15,7 +15,6 @@ def __init__(self) -> None: def connect(self): with open(os.getenv("DB_PASSWORD_FILE"), "r", encoding="utf-8") as f: password = f.read().strip() - print(password, flush=True) try: self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv( "DB_USER"), password=password, database=os.getenv("DB_DATABASE"), autocommit=True) From 8ae486c56574c3e756632e55e76db6dc16128e68 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Sat, 25 Jan 2025 01:48:31 +1100 Subject: [PATCH 11/26] Added queue timeout, renamed Team A/B with team name, queue prompt --- README.md | 13 ++ src/lib/cogs/SixMans.py | 457 ++++++++++++++++++++++++++-------------- src/utils/SixMans.py | 57 ++++- 3 files changed, 363 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index b50d506..14fed99 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,16 @@ If you would like to run locally: | `db-root-password` | Root password for the database | 4. Copy [/db/.env.template](./db/.env.template) to `db/.env`, and adjust values as necessary. 5. Run the bot with `docker compose up -d --build`. + +# Feedback + +- [x] Implement a queue timeout, perhaps 45-60mins? +- [x] Notify the channel when a player has joined the queue +- [ ] Have options to do a 1s (best of 1), 2s (best of 1), 3s (best of 3) and/or have the option to configure the game to be a best of 1 or best of 3 +- [ ] Have request to spectate six mans matches +- [x] Wait for all 6 people to join call before starting otherwise cancel after 5 mins +- [ ] Ability to substitute players into the game? +- [ ] Incorporate personal stats page +- [x] Disable general text chat messaging +- [x] Change Team A/B to actual team names +- [ ] Add rematch button diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index c3fd835..5465bb2 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -1,3 +1,4 @@ +import asyncio from discord.ext import commands from discord.ui import Button, View, Select, Modal, TextInput from discord import app_commands, PermissionOverwrite, Object @@ -6,21 +7,36 @@ import discord import logging -from src.utils.SixMans import PARTY_SIZE, SixMansState, SixMansMatchType, SixMansParty +from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, QUEUE_TIMEOUT, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty + class SixMansPrompt(View): def __init__(self, bot: discord.Client, party_id: int): super().__init__(timeout=None) self.bot = bot self.message: Union[None | discord.Message] = None - - self.state = SixMansState.CHOOSE_CAPTAIN_ONE + + self.state = SixMansState.PRE_LOBBY self.game = SixMansMatchType.PRE_MATCH - + self.party = SixMansParty(bot, party_id) - + self.bot.event(self.on_voice_state_update) + + @commands.Cog.listener() + async def on_voice_state_update(self, member, before, after): + if before.channel is not None and after.channel is not None and before.channel == after.channel: + return + if member.bot: + return + + # User joins channel + if str(after.channel.id) == (await self.party.get_details())["VoiceChannelID"] and len(after.channel.members) == PARTY_SIZE: + self.state = SixMansState.CHOOSE_CAPTAIN_ONE + await self.update_view() + async def interaction_check(self, interaction: discord.Interaction): - if "custom_id" in interaction.data and interaction.data["custom_id"] == "team_comp": return True + if "custom_id" in interaction.data and interaction.data["custom_id"] == "team_comp": + return True match self.state: case SixMansState.CHOOSE_CAPTAIN_ONE: if interaction.user and interaction.user.id != self.party.captain_one.id: @@ -34,21 +50,23 @@ async def interaction_check(self, interaction: discord.Interaction): if interaction.user and interaction.user.id not in [self.party.captain_one.id, self.party.captain_two.id]: await interaction.response.send_message(f"Only {self.party.captain_one.mention} or {self.party.captain_two.mention} can do this.", ephemeral=True) return interaction.user and interaction.user.id in [self.party.captain_one.id, self.party.captain_two.id] - + async def delete_lobby(self): lobby_details = await self.party.get_details() lobby_name = f"SixMans #{self.party.lobby_id}" - - guild = self.bot.get_channel(int(lobby_details["VoiceChannelID"])).guild + + guild = self.bot.get_channel( + int(lobby_details["VoiceChannelID"])).guild await (guild.get_role(int(lobby_details["RoleID"]))).delete(reason=f"{lobby_name} finished/cancelled") await (self.bot.get_channel(int(lobby_details["VoiceChannelID"]))).delete(reason=f"{lobby_name} finished/cancelled") await (self.bot.get_channel(int(lobby_details["TextChannelID"]))).delete(reason=f"{lobby_name} finished/cancelled") - + if lobby_details["VoiceChannelA"]: await (self.bot.get_channel(int(lobby_details["VoiceChannelA"]))).delete(reason=f"{lobby_name} finished/cancelled") if lobby_details["VoiceChannelB"]: await (self.bot.get_channel(int(lobby_details["VoiceChannelB"]))).delete(reason=f"{lobby_name} finished/cancelled") - DB.execute("DELETE FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id) + DB.execute("DELETE FROM SixManLobby WHERE LobbyID = %s", + self.party.lobby_id) def get_match_type(self): match self.game: @@ -59,7 +77,7 @@ def get_match_type(self): case SixMansMatchType.THREE_V_THREE: match_type = "3v3" return match_type - + async def create_break_out_rooms(self): lobby_a = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_one.display_name}" lobby_b = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_two.display_name}" @@ -68,14 +86,17 @@ async def create_break_out_rooms(self): team_two_players = self.party.get_players(2) guild: discord.Guild = self.message.guild - voice_channel_a_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True,connect=True,view_channel=True)), team_one_players))) | {guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} - voice_channel_b_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True,connect=True,view_channel=True)), team_two_players))) | {guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + voice_channel_a_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True, connect=True, view_channel=True)), team_one_players))) | { + guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + voice_channel_b_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True, connect=True, view_channel=True)), team_two_players))) | { + guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} voice_channel_a = await guild.create_voice_channel(name=lobby_a, overwrites=voice_channel_a_perms, category=self.message.channel.category, reason=f"{lobby_a} created") voice_channel_b = await guild.create_voice_channel(name=lobby_b, overwrites=voice_channel_b_perms, category=self.message.channel.category, reason=f"{lobby_b} created") - DB.execute("UPDATE SixManLobby SET VoiceChannelA = %s, VoiceChannelB = %s WHERE LobbyID = %s", voice_channel_a.id, voice_channel_b.id, self.party.lobby_id) - + DB.execute("UPDATE SixManLobby SET VoiceChannelA = %s, VoiceChannelB = %s WHERE LobbyID = %s", + voice_channel_a.id, voice_channel_b.id, self.party.lobby_id) + # Move members for member in guild.get_channel(int(DB.field("SELECT VoiceChannelID FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id))).members: if member in team_one_players: @@ -84,7 +105,8 @@ async def create_break_out_rooms(self): await member.move_to(voice_channel_b) def generate_flag_str(self, member: discord.Member): - data = DB.row("SELECT Type, isOnesPlayer FROM SixManUsers WHERE PartyID = %s AND UserID = %s", self.party.party_id, member.id) + data = DB.row("SELECT Type, isOnesPlayer FROM SixManUsers WHERE PartyID = %s AND UserID = %s", + self.party.party_id, member.id) flags = [] match data["Type"]: case 1 | 2: @@ -99,58 +121,90 @@ def generate_flag_str(self, member: discord.Member): return ", ".join(flags) def generate_match_summary(self) -> str: - scores = [0 if x is None else x for x in DB.row("SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.party.game_id).values()] - team_a = ['W' if x else '-' for x in [scores[i] > scores[i + 1] for i in range(0, len(scores), 2)]] - team_b = ['W' if x else '-' for x in [scores[i+1] > scores[i] for i in range(0, len(scores), 2)]] - - return (f"""``` -| | 1v1 | 2v2 | 3v3 | -|--------|-----|-----|-----| -| Team A | {team_a[0]} | {team_a[1]} | {team_a[2]} | -| Team B | {team_b[0]} | {team_b[1]} | {team_b[2]} |```""") - + scores = [0 if x is None else x for x in DB.row( + "SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.party.game_id).values()] + team_a = ['W' if x else '-' for x in [scores[i] > scores[i + 1] + for i in range(0, len(scores), 2)]] + team_b = ['W' if x else '-' for x in [scores[i+1] > scores[i] + for i in range(0, len(scores), 2)]] + name_1 = f"Team {self.party.captain_one.display_name}" + name_2 = f"Team {self.party.captain_two.display_name}" + max_len = max(len(name_1), len(name_2)) + + return f"""``` +| {' '* (max_len)} | 1v1 | 2v2 | 3v3 | +| {'-'* (max_len)} |-----|-----|-----| +| {name_1:<{max_len}} | {team_a[0]} | {team_a[1]} | {team_a[2]} | +| {name_2:<{max_len}} | {team_b[0]} | {team_b[1]} | {team_b[2]} |```""" + def generate_match_composition(self) -> str: - players = DB.rows("SELECT UserID, Type, Team, isOnesPlayer FROM SixManUsers WHERE PartyID = %s", self.party.party_id) - - ones_a = [row["UserID"] for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 1] - ones_b = [row["UserID"] for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 2] - - twos_a = [row["UserID"] for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 1] - twos_b = [row["UserID"] for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 2] - + players = DB.rows( + "SELECT UserID, Type, Team, isOnesPlayer FROM SixManUsers WHERE PartyID = %s", self.party.party_id) + + ones_a = [row["UserID"] + for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 1] + ones_b = [row["UserID"] + for row in players if row["isOnesPlayer"] == 1 and row["Team"] == 2] + + twos_a = [row["UserID"] + for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 1] + twos_b = [row["UserID"] + for row in players if row["isOnesPlayer"] == 0 and row["Team"] == 2] + threes_a = [row["UserID"] for row in players if row["Team"] == 1] threes_b = [row["UserID"] for row in players if row["Team"] == 2] return (f"""### 1v1 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.ONE_V_ONE else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_a]) if ones_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_b]) if ones_b else 'TBD'} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_a]) if ones_a else 'TBD'} **vs** { + ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_b]) if ones_b else 'TBD'} ### 2v2 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.TWO_V_TWO else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_a]) if twos_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_b]) if twos_b else 'TBD'} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_a]) if twos_a else 'TBD'} **vs** { + ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_b]) if twos_b else 'TBD'} ### 3v3 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.THREE_V_THREE else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_a]) if threes_a else 'TBD'} **vs** {', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_b]) if threes_b else 'TBD'} +{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_a]) if threes_a else 'TBD'} **vs** { + ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_b]) if threes_b else 'TBD'} """) def generate_embed(self): team_one_players = self.party.get_players(1) team_two_players = self.party.get_players(2) - team_one_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] - team_two_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] + team_one_str = [ + f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] + team_two_str = [ + f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] match self.state: + case SixMansState.PRE_LOBBY: + description = f"Hello! Welcome to {self.bot.user.mention} Six Mans!\n\nSix Mans will start once all players have joined the voice channel. Otherwise, if all players have not connected within {LOBBY_TIMEOUT} minutes, the lobby will automatically be deleted.\n\n" + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + return embed case SixMansState.CHOOSE_CAPTAIN_ONE: description = f"Hello! Welcome to {self.bot.user.mention} Six Mans!\n\nTo begin, {self.party.captain_one.mention} needs to select **ONE** player to join their team.\n\n" - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) - embed.add_field(name="Team A", value="\n".join(team_one_str)) - embed.add_field(name="Team B", value="\n".join(team_two_str)) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field( + name=f"Team {self.party.captain_one.display_name}", value="\n".join(team_one_str)) + embed.add_field( + name=f"Team {self.party.captain_two.display_name}", value="\n".join(team_two_str)) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") return embed case SixMansState.CHOOSE_CAPTAIN_TWO: description = f"{self.party.captain_two.mention} now needs to select **TWO** players to join their team.\n\n" - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) - embed.add_field(name="Team A", value="\n".join(team_one_str)) - embed.add_field(name="Team B", value="\n".join(team_two_str)) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field( + name=f"Team {self.party.captain_one.display_name}", value="\n".join(team_one_str)) + embed.add_field( + name=f"Team {self.party.captain_two.display_name}", value="\n".join(team_two_str)) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") return embed case SixMansState.CHOOSE_1S_PLAYER: - ones_players = (DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 1", self.party.party_id), DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 2", self.party.party_id)) + ones_players = (DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 1", self.party.party_id), DB.field( + "SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1 AND Team = 2", self.party.party_id)) if ones_players[0] == None and ones_players[1] == None: waiting_message = "both team captains to nominate a player.**" elif ones_players[0] == None and ones_players[1] != None: @@ -158,10 +212,14 @@ def generate_embed(self): else: waiting_message = f"{self.party.captain_two.mention} to nominate a player.**" description = f"Perfect! We now have our teams sorted. We will start off with the 1v1 match.\n\nTo begin, each team captain needs to nominate a player from their own team to play their 1s match. You can do this by clicking the **Nominate player** button below.\n\n**Currently waiting on {waiting_message}" - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) - embed.add_field(name="Team A", value="\n".join(team_one_str)) - embed.add_field(name="Team B", value="\n".join(team_two_str)) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.add_field( + name=f"Team {self.party.captain_one.display_name}", value="\n".join(team_one_str)) + embed.add_field( + name=f"Team {self.party.captain_two.display_name}", value="\n".join(team_two_str)) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") return embed case SixMansState.PLAYING: match self.game: @@ -172,7 +230,7 @@ def generate_embed(self): description = f"You are now playing the {match_type} match. **Don't forget to come back here before your next game starts.**\n\n{self.generate_match_summary()}\n{self.generate_match_composition()}\n\nOnce you have finished your {match_type} match, click on the **Finish {match_type}** button below. Best of luck!" case SixMansState.SCORE_UPLOAD: match_type = self.get_match_type() - + team_a_reported_score = self.party.reported_scores[self.party.captain_one.id][match_type] team_b_reported_score = self.party.reported_scores[self.party.captain_two.id][match_type] @@ -189,14 +247,16 @@ def generate_embed(self): case SixMansState.POST_MATCH: match self.party.calculate_winner(): case 1: - winner = "A" + winner = self.party.captain_one.display_name case 2: - winner = "B" + winner = self.party.captain_two.display_name description = f"### Congratulations Team {winner}!\nYou have won the six mans scrims!\n\nPress the **End Game** button below when you are done. Thanks for playing!" case _: description = "Uh oh! Something has gone wrong." - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") return embed def add_button(self, label: str, style: discord.ButtonStyle, callback: Callable[[discord.Interaction], Any], **kwargs): @@ -208,26 +268,38 @@ def update_options(self): self.clear_items() match self.state: case SixMansState.CHOOSE_CAPTAIN_ONE | SixMansState.CHOOSE_CAPTAIN_TWO: - self.add_button("Choose Player(s)", discord.ButtonStyle.blurple, self.choose_button_callback) - self.add_button("Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) + self.add_button( + "Choose Player(s)", discord.ButtonStyle.blurple, self.choose_button_callback) + self.add_button( + "Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) case SixMansState.CHOOSE_1S_PLAYER: - self.add_button("Nominate Player", discord.ButtonStyle.blurple, self.choose_button_callback) - self.add_button("Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) + self.add_button( + "Nominate Player", discord.ButtonStyle.blurple, self.choose_button_callback) + self.add_button( + "Cancel Game", discord.ButtonStyle.red, self.cancel_button_callback) case SixMansState.PLAYING: match self.game: case SixMansMatchType.PRE_MATCH: - self.add_button("Break Out", discord.ButtonStyle.blurple, self.break_out_button_callback) - self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + self.add_button( + "Break Out", discord.ButtonStyle.blurple, self.break_out_button_callback) + self.add_button("View Team Composition", discord.ButtonStyle.grey, + self.team_composition_callback, custom_id="team_comp") case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: match_label = self.get_match_type() - self.add_button(f"Finish {match_label}", discord.ButtonStyle.blurple, self.finish_button_callback) - self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") - self.add_button(f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) + self.add_button( + f"Finish {match_label}", discord.ButtonStyle.blurple, self.finish_button_callback) + self.add_button("View Team Composition", discord.ButtonStyle.grey, + self.team_composition_callback, custom_id="team_comp") + self.add_button( + f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) case SixMansState.SCORE_UPLOAD: - self.add_button("Upload Scores", discord.ButtonStyle.blurple, self.upload_scores_callback) - self.add_button("View team composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + self.add_button( + "Upload Scores", discord.ButtonStyle.blurple, self.upload_scores_callback) + self.add_button("View team composition", discord.ButtonStyle.grey, + self.team_composition_callback, custom_id="team_comp") case SixMansState.POST_MATCH: - self.add_button("End Game", discord.ButtonStyle.red, self.cancel_button_callback) + self.add_button("End Game", discord.ButtonStyle.red, + self.cancel_button_callback) async def update_view(self, embed=None): self.update_options() @@ -236,30 +308,35 @@ async def update_view(self, embed=None): async def cancel_button_callback(self, _: discord.Interaction): await self.delete_lobby() - + async def choose_button_callback(self, interaction: discord.Interaction): match self.state: case SixMansState.CHOOSE_CAPTAIN_ONE | SixMansState.CHOOSE_CAPTAIN_TWO: - users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.party.party_id)] + users = [interaction.client.get_user(int(user_id)) for user_id in DB.column( + "SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.party.party_id)] reason = "join your team." case SixMansState.CHOOSE_1S_PLAYER: - users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party.party_id, DB.field("SELECT Team FROM SixManUsers WHERE UserID = %s AND PartyID = %s", interaction.user.id, self.party.party_id))] + users = [interaction.client.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party.party_id, DB.field( + "SELECT Team FROM SixManUsers WHERE UserID = %s AND PartyID = %s", interaction.user.id, self.party.party_id))] reason = "play your 1s match." - view = UserDropdownView(self, message_interaction=interaction, users=users, amount=1 if self.state in [SixMansState.CHOOSE_CAPTAIN_ONE, SixMansState.CHOOSE_1S_PLAYER] else 2 if self.state == SixMansState.CHOOSE_CAPTAIN_TWO else 0, reason=reason) + view = UserDropdownView(self, message_interaction=interaction, users=users, amount=1 if self.state in [ + SixMansState.CHOOSE_CAPTAIN_ONE, SixMansState.CHOOSE_1S_PLAYER] else 2 if self.state == SixMansState.CHOOSE_CAPTAIN_TWO else 0, reason=reason) await interaction.response.send_message(view=view, ephemeral=True) - + async def break_out_button_callback(self, interaction: discord.Interaction): await interaction.response.defer() await self.create_break_out_rooms() self.game = SixMansMatchType.ONE_V_ONE DB.execute("INSERT INTO SixManGames () VALUES ()") self.party.game_id = DB.field("SELECT LAST_INSERT_ID()") - DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", self.party.game_id, self.party.party_id) + DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", + self.party.game_id, self.party.party_id) await self.update_view() - + async def surrender_button_callback(self, interaction: discord.Interaction): match_type = self.get_match_type() - DB.execute(f"UPDATE SixManGames SET {match_type}_{'A' if interaction.user.id == self.party.captain_one.id else 'B'} = %s WHERE GameID = %s", -1, self.party.game_id) + DB.execute( + f"UPDATE SixManGames SET {match_type}_{'A' if interaction.user.id == self.party.captain_one.id else 'B'} = %s WHERE GameID = %s", -1, self.party.game_id) if self.party.calculate_winner() == 0: self.game = SixMansMatchType.THREE_V_THREE if self.game == SixMansMatchType.TWO_V_TWO else SixMansMatchType.TWO_V_TWO self.state = SixMansState.PLAYING @@ -267,34 +344,45 @@ async def surrender_button_callback(self, interaction: discord.Interaction): self.state = SixMansState.POST_MATCH await self.update_view() return await interaction.response.send_message(f"You have surrendered the {match_type} match.", ephemeral=True) - + async def upload_scores_callback(self, interaction: discord.Interaction): match_type = self.get_match_type() await interaction.response.send_modal(ScoreUpload(self, match_type)) - + async def finish_button_callback(self, interaction: discord.Interaction): await interaction.response.defer() self.state = SixMansState.SCORE_UPLOAD await self.update_view() - + async def team_composition_callback(self, interaction: discord.Interaction): team_one_players = self.party.get_players(1) team_two_players = self.party.get_players(2) - team_one_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] - team_two_str = [f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] - embed: discord.Embed = self.bot.create_embed(f"ScuffBot Six Mans #{self.party.lobby_id}", "", None) - embed.add_field(name="Team A", value="\n".join(team_one_str)) - embed.add_field(name="Team B", value="\n".join(team_two_str)) - embed.set_footer(text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + team_one_str = [ + f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_one_players] + team_two_str = [ + f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", "", None) + embed.add_field( + name=f"Team {self.party.captain_one.display_name}", value="\n".join(team_one_str)) + embed.add_field( + name=f"Team {self.party.captain_two.display_name}", value="\n".join(team_two_str)) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") await interaction.response.send_message(embed=embed, ephemeral=True) - + + class ScoreUpload(Modal): - score_a = TextInput(label='Team A Score') - score_b = TextInput(label='Team B Score') + score_a = TextInput( + label=f'Team A Score') + score_b = TextInput( + label=f'Team B Score') def __init__(self, ctx: SixMansPrompt, title: str) -> None: - self.ctx = ctx super().__init__(title=title) + self.ctx = ctx + self.score_a.label = f'Team {self.ctx.party.captain_one.display_name} Score' + self.score_b.label = f'Team {self.ctx.party.captain_two.display_name} Score' async def on_submit(self, interaction: discord.Interaction): await interaction.response.defer() @@ -305,15 +393,19 @@ async def on_submit(self, interaction: discord.Interaction): elif (not self.score_a.value.isnumeric()) or (not self.score_b.value.isnumeric()): return await interaction.followup.send("One or more of your scores is not valid. Please ensure they are non-negative numbers.", ephemeral=True) else: - self.ctx.party.reported_scores[interaction.user.id][match_type] = (int(self.score_a.value), int(self.score_b.value)) + self.ctx.party.reported_scores[interaction.user.id][match_type] = ( + int(self.score_a.value), int(self.score_b.value)) # Check if both scores are present and compare - team_a_reported_score = self.ctx.party.reported_scores[self.ctx.party.captain_one.id][match_type] - team_b_reported_score = self.ctx.party.reported_scores[self.ctx.party.captain_two.id][match_type] + team_a_reported_score = self.ctx.party.reported_scores[ + self.ctx.party.captain_one.id][match_type] + team_b_reported_score = self.ctx.party.reported_scores[ + self.ctx.party.captain_two.id][match_type] if team_a_reported_score == team_b_reported_score: # Both scores set and equal - DB.execute(f"UPDATE SixManGames SET {match_type}_A = %s, {match_type}_B = %s WHERE GameID = %s", team_a_reported_score[0], team_b_reported_score[1], self.ctx.party.game_id) + DB.execute(f"UPDATE SixManGames SET {match_type}_A = %s, {match_type}_B = %s WHERE GameID = %s", + team_a_reported_score[0], team_b_reported_score[1], self.ctx.party.game_id) match self.ctx.game: case SixMansMatchType.ONE_V_ONE: self.ctx.game = SixMansMatchType.TWO_V_TWO @@ -328,29 +420,89 @@ async def on_submit(self, interaction: discord.Interaction): self.ctx.state = SixMansState.POST_MATCH await self.ctx.update_view() + +class QueuePrompt(View): + + def __init__(self, ctx, message: discord.Message): + super().__init__(timeout=None) + self.ctx = ctx + self.message = message + + join_button = Button( + label="Join Queue", style=discord.ButtonStyle.green) + join_button.callback = self.join_callback + self.add_item(join_button) + + leave_button = Button( + label="Leave Queue", style=discord.ButtonStyle.grey) + leave_button.callback = self.leave_callback + self.add_item(leave_button) + + def generate_embed(self): + queue_len = len(self.ctx.player_queue) + return self.ctx.bot.create_embed("SCUFFBOT SIX MANS", f"To join/leave the six man queue, click the respective button below.\n\nThere {'is' if queue_len == 1 else 'are'} currently **{queue_len} {'player' if queue_len == 1 else 'players'}** in the queue.", None) + + async def update_view(self, embed=None): + if self.message: + await self.message.edit(embed=embed or self.generate_embed(), view=self) + + async def join_callback(self, interaction: discord.Interaction): + if interaction.user in self.ctx.player_queue: + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are already in the queue.", None), ephemeral=True) + elif str(interaction.user.id) in DB.column("SELECT A.UserID FROM SixManUsers A INNER JOIN SixManParty B WHERE A.PartyID = B.PartyID AND B.LobbyID IS NOT NULL"): + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are already in a six mans lobby. Failed to join queue.", None), ephemeral=True) + else: + self.ctx.player_queue.add(interaction.user) + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.ctx.player_queue)}/{PARTY_SIZE})", None), ephemeral=True) + await self.update_view() + if ((party := self.ctx.player_queue.get_party())): + await self.update_view() + lobby_id, party_id = await self.ctx.create_party(party) + await self.ctx.start(lobby_id, party_id) + else: + await asyncio.sleep(QUEUE_TIMEOUT * 60) + if interaction.user in self.ctx.player_queue: + self.ctx.player_queue.remove(interaction.user) + await interaction.user.send(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have been removed from the Six Mans queue since a game could not be found in time.", None)) + await self.update_view() + + async def leave_callback(self, interaction: discord.Interaction): + if not interaction.user in self.ctx.player_queue: + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You are not in a queue. Click the `Join Queue` button to join the queue.", None), ephemeral=True) + else: + self.ctx.player_queue.remove(interaction.user) + await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) + await self.update_view() + + class SixMans(commands.Cog): def __init__(self, bot: discord.Client): self.bot = bot self.logger = logging.getLogger(__name__) - self.queue = list() + self.player_queue = SixMansQueue() self.category = config["SIX_MAN"]["CATEGORY"] async def cog_load(self): + asyncio.create_task(self.create_queue_prompt()) self.logger.info(f"[COG] Loaded {self.__class__.__name__}") - async def check_queue(self): - if len(self.queue) == PARTY_SIZE: - party = self.queue[:PARTY_SIZE] - del self.queue[:PARTY_SIZE] - lobby_id, party_id = await self.create_party(party) - await self.start(lobby_id, party_id) + async def create_queue_prompt(self): + await self.bot.wait_until_ready() + category_channel = self.bot.get_channel(self.category) + if "join-queue" not in [channel.name for channel in category_channel.text_channels]: + channel = await category_channel.create_text_channel("join-queue", overwrites={category_channel.guild.default_role: PermissionOverwrite(send_messages=False, view_channel=True)}) + await asyncio.sleep(3) + message = await channel.send(None, embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"To join/leave the six man queue, click the respective button below.", None)) + view = QueuePrompt(self, message) + await view.update_view() async def generate_lobby_id(self): - highest_lobby_num = max([0] + (lobbies := DB.column("SELECT LobbyID FROM SixManParty WHERE LobbyID IS NOT NULL"))) + highest_lobby_num = max( + [0] + (lobbies := DB.column("SELECT LobbyID FROM SixManParty WHERE LobbyID IS NOT NULL"))) r = list(range(1, highest_lobby_num)) possible_lobby_nums = [i for i in r if i not in lobbies] return highest_lobby_num + 1 if not possible_lobby_nums else possible_lobby_nums.pop(0) - + async def create_party(self, members): lobby_id = await self.generate_lobby_id() lobby_name = f"SixMans #{lobby_id}" @@ -359,65 +511,52 @@ async def create_party(self, members): # Create role lobby_role = await guild.create_role(name=lobby_name, reason=f"{lobby_name} created") - + # Create perms - vc_perms = {lobby_role: PermissionOverwrite(speak=True, connect=True, view_channel=True), guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} - text_perms = {lobby_role: PermissionOverwrite(read_messages=True, send_messages=True), guild.default_role: PermissionOverwrite(read_messages=False)} - + vc_perms = {lobby_role: PermissionOverwrite( + speak=True, connect=True, view_channel=True), guild.default_role: PermissionOverwrite(view_channel=False, connect=False)} + text_perms = {lobby_role: PermissionOverwrite( + read_messages=True, send_messages=False), guild.default_role: PermissionOverwrite(read_messages=False, view_channel=False)} + # Create voice channel voice_channel = await guild.create_voice_channel(name=lobby_name, overwrites=vc_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") - + # Create text channel text_channel = await guild.create_text_channel(name=f"six-mans-{lobby_id}", overwrites=text_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") - + # Send preliminary starting message message = await text_channel.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"SCUFFBOT is creating the six mans lobby. This message will update once complete.", None)) - + lobby_invite_link = str(await voice_channel.create_invite(reason=f"{lobby_name} created")) - DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) + DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", + lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) DB.execute("INSERT INTO SixManParty (LobbyID) VALUES (%s)", lobby_id) party_id = DB.field("SELECT LAST_INSERT_ID()") - + # Invite members for member in members: await member.add_roles(lobby_role, reason=f"{member} added to {lobby_name}") - await member.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have been added into **{lobby_name}**.", None), view=View().add_item(discord.ui.Button(label="Join Six Mans",style=discord.ButtonStyle.link, url=lobby_invite_link))) - DB.execute("INSERT INTO SixManUsers (PartyID, UserID) VALUES (%s, %s)", party_id, member.id) + await member.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have been added into **{lobby_name}**. Failing to join within {LOBBY_TIMEOUT} minutes will drop the match.", None), view=View().add_item(discord.ui.Button(label="Join Six Mans", style=discord.ButtonStyle.link, url=lobby_invite_link))) + DB.execute( + "INSERT INTO SixManUsers (PartyID, UserID) VALUES (%s, %s)", party_id, member.id) return (lobby_id, party_id) - + async def start(self, lobby_id: int, party_id: int): - text_channel = self.bot.get_channel(int(DB.field("SELECT TextChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) + text_channel = self.bot.get_channel(int(DB.field( + "SELECT TextChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) + voice_channel = self.bot.get_channel(int(DB.field( + "SELECT VoiceChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) message = await text_channel.fetch_message(int(DB.field("SELECT MessageID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) view = SixMansPrompt(self.bot, party_id) view.message = message await view.update_view() - - @app_commands.command(name="q", description="Joins the six man queue.") - async def queue(self, interaction: discord.Interaction): - if interaction.user in self.queue: - self.queue.remove(interaction.user) - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) - else: - if str(interaction.user.id) in DB.column("SELECT A.UserID FROM SixManUsers A INNER JOIN SixManParty B WHERE A.PartyID = B.PartyID AND B.LobbyID IS NOT NULL"): - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You are already in a six mans lobby. Failed to join queue.", None), ephemeral=True) - else: - self.queue.append(interaction.user) - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.queue)}/{PARTY_SIZE})", None), ephemeral=True) - await self.check_queue() - - @app_commands.command(name="leave", description="Leaves the six man queue.") - async def leave(self, interaction: discord.Interaction): - if not interaction.user in self.queue: - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You are not in a queue. Type `/q` to join the queue.", None), ephemeral=True) - else: - self.queue.remove(interaction.user) - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have left the six mans queue.", None), ephemeral=True) - - @app_commands.command(name="status", description="Returns the number of people in the queue.") - async def status(self, interaction: discord.Interaction): - queue_len = len(self.queue) - await interaction.response.send_message(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"There {'is' if queue_len == 1 else 'are'} currently {len(self.queue)} {'player' if queue_len == 1 else 'players'} in the queue.", None)) + + # Delete lobby if not all people have joined + await asyncio.sleep(LOBBY_TIMEOUT * 60) + if len(voice_channel.members) != PARTY_SIZE and view.state == SixMansState.PRE_LOBBY: + await view.delete_lobby() + async def setup(bot): await bot.add_cog(SixMans(bot)) @@ -425,23 +564,27 @@ async def setup(bot): class UserDropdown(Select): def __init__(self, users: list[discord.Member], amount: int, reason: str): - options = [discord.SelectOption(label=user.display_name, value=user.id) for user in users] - super().__init__(placeholder=f"Choose {amount} {'member' if amount == 1 else 'members'} to {reason}", min_values=amount, max_values=amount, options=options) + options = [discord.SelectOption( + label=user.display_name, value=user.id) for user in users] + super().__init__(placeholder=f"Choose {amount} {'member' if amount == 1 else 'members'} to {reason}", + min_values=amount, max_values=amount, options=options) async def callback(self, interaction: discord.Interaction): await interaction.response.defer() + class UserDropdownView(View): def __init__(self, ctx: SixMansPrompt, message_interaction: discord.Interaction, users: list[discord.Member], amount: int, reason: str): super().__init__(timeout=None) self.ctx = ctx self.message_interaction = message_interaction self.reason = reason - + self.user_dropdown = UserDropdown(users, amount, reason) self.add_item(self.user_dropdown) - - self.submit_button = discord.ui.Button(label="Submit", style=discord.ButtonStyle.green) + + self.submit_button = discord.ui.Button( + label="Submit", style=discord.ButtonStyle.green) self.submit_button.callback = self.submit_button_callback self.add_item(self.submit_button) @@ -449,19 +592,25 @@ async def submit_button_callback(self, interaction: discord.Interaction): match self.ctx.state: case SixMansState.CHOOSE_CAPTAIN_ONE: for user_id in self.user_dropdown.values: - DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s AND PartyID = %s", user_id, self.ctx.party.party_id) + DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s AND PartyID = %s", + user_id, self.ctx.party.party_id) self.ctx.state = SixMansState.CHOOSE_CAPTAIN_TWO case SixMansState.CHOOSE_CAPTAIN_TWO: for user_id in self.user_dropdown.values: - DB.execute("UPDATE SixManUsers SET Team = 2 WHERE UserID = %s AND PartyID = %s", user_id, self.ctx.party.party_id) + DB.execute("UPDATE SixManUsers SET Team = 2 WHERE UserID = %s AND PartyID = %s", + user_id, self.ctx.party.party_id) # Add last player - last_player_id = DB.field("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.ctx.party.party_id) - DB.execute("UPDATE SixManUsers SET Team = 1 WHERE UserID = %s", last_player_id) + last_player_id = DB.field( + "SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = 0", self.ctx.party.party_id) + DB.execute( + "UPDATE SixManUsers SET Team = 1 WHERE UserID = %s", last_player_id) self.ctx.state = SixMansState.CHOOSE_1S_PLAYER case SixMansState.CHOOSE_1S_PLAYER: - DB.execute("UPDATE SixManUsers SET isOnesPlayer = 1 WHERE UserID = %s AND PartyID = %s", self.user_dropdown.values[0], self.ctx.party.party_id) + DB.execute("UPDATE SixManUsers SET isOnesPlayer = 1 WHERE UserID = %s AND PartyID = %s", + self.user_dropdown.values[0], self.ctx.party.party_id) if len(DB.rows("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND isOnesPlayer = 1", self.ctx.party.party_id)) == 2: - DB.execute("UPDATE SixManUsers SET isOnesPlayer = 0 WHERE PartyID = %s AND isOnesPlayer IS NULL", self.ctx.party.party_id) + DB.execute( + "UPDATE SixManUsers SET isOnesPlayer = 0 WHERE PartyID = %s AND isOnesPlayer IS NULL", self.ctx.party.party_id) self.ctx.state = SixMansState.PLAYING await self.message_interaction.edit_original_response(content=f"You have selected {', '.join([interaction.client.get_user(int(user_id)).mention for user_id in self.user_dropdown.values])} to {self.reason}", view=None) await self.ctx.update_view() diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py index 5ee3c68..17e97f2 100644 --- a/src/utils/SixMans.py +++ b/src/utils/SixMans.py @@ -6,8 +6,12 @@ from src.lib.bot import DB PARTY_SIZE = 6 +QUEUE_TIMEOUT = 60 # minutes +LOBBY_TIMEOUT = 5 # minutes + class SixMansState(Enum): + PRE_LOBBY = 0 CHOOSE_CAPTAIN_ONE = 1 CHOOSE_CAPTAIN_TWO = 2 CHOOSE_1S_PLAYER = 3 @@ -15,45 +19,54 @@ class SixMansState(Enum): SCORE_UPLOAD = 5 POST_MATCH = 6 + class SixMansMatchType(Enum): PRE_MATCH = 0 ONE_V_ONE = 1 TWO_V_TWO = 2 THREE_V_THREE = 3 + class SixMansParty(): def __init__(self, bot: discord.Client, party_id: int) -> None: self.bot = bot self.game_id: Union[None, int] = None self.party_id = party_id - self.lobby_id = DB.field("SELECT LobbyID FROM SixManParty WHERE PartyID = %s", self.party_id) + self.lobby_id = DB.field( + "SELECT LobbyID FROM SixManParty WHERE PartyID = %s", self.party_id) self.players = self.get_players() self.captain_one: Union[None, discord.Member] = None self.captain_two: Union[None, discord.Member] = None self.generate_captains() - - self.reported_scores = {self.captain_one.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}, self.captain_two.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}} + + self.reported_scores = {self.captain_one.id: {"1v1": (None, None), "2v2": (None, None), "3v3": ( + None, None)}, self.captain_two.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}} async def get_details(self): return DB.row("SELECT * FROM SixManLobby WHERE LobbyID = %s", self.lobby_id) - + def get_players(self, team: Literal[None, 1, 2] = None): return [self.bot.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party_id, 0 if team == None else team)] - + def generate_captains(self): players = self.get_players() self.captain_one = players.pop(random.randint(0, len(players)-1)) self.captain_two = players.pop(random.randint(0, len(players)-1)) - DB.execute("UPDATE SixManUsers SET Type = 1, Team = 1 WHERE UserID = %s", self.captain_one.id) - DB.execute("UPDATE SixManUsers SET Type = 2, Team = 2 WHERE UserID = %s", self.captain_two.id) + DB.execute( + "UPDATE SixManUsers SET Type = 1, Team = 1 WHERE UserID = %s", self.captain_one.id) + DB.execute( + "UPDATE SixManUsers SET Type = 2, Team = 2 WHERE UserID = %s", self.captain_two.id) def calculate_winner(self) -> Literal[0, 1, 2]: if self.game_id == None: return 0 - data = [0 if x is None else x for x in DB.row("SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.game_id).values()] + data = [0 if x is None else x for x in DB.row( + "SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.game_id).values()] - team_a_wins = sum(data[i] > data[i + 1] for i in range(0, len(data), 2)) - team_b_wins = sum(data[i + 1] > data[i] for i in range(0, len(data), 2)) + team_a_wins = sum(data[i] > data[i + 1] + for i in range(0, len(data), 2)) + team_b_wins = sum(data[i + 1] > data[i] + for i in range(0, len(data), 2)) match (team_a_wins >= 2, team_b_wins >= 2): case (True, False): @@ -62,3 +75,27 @@ def calculate_winner(self) -> Literal[0, 1, 2]: return 2 case (False, False): return 0 + + +class SixMansQueue(): + def __init__(self): + self.queue = list() + + def add(self, player: discord.Member): + self.queue.append(player) + + def remove(self, player: discord.Member): + self.queue.remove(player) + + def get_party(self): + if len(self.queue) == PARTY_SIZE: + party = self.queue[:PARTY_SIZE] + del self.queue[:PARTY_SIZE] + return party + return [] + + def __contains__(self, key): + return key in self.queue + + def __len__(self): + return len(self.queue) From b659ecfe37afe3af89d44fd225967d5f2502df25 Mon Sep 17 00:00:00 2001 From: Sam Zheng Date: Sun, 2 Feb 2025 13:52:58 +1300 Subject: [PATCH 12/26] =?UTF-8?q?=F0=9F=94=A7=20=20Update=20helm=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updating helm chart version to the new consolidated helm chart, and other changes for consistency. --- .github/workflows/deploy.yaml | 2 +- infra/values.yaml | 25 ++++++++++++------------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 3304c10..a787a0d 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -101,6 +101,6 @@ jobs: BOT_TOKEN: ${{ needs.build.outputs.environment == 'prod' && secrets.BOT_TOKEN_PROD || secrets.BOT_TOKEN_STAGING }} run: | cat infra/values.yaml | envsubst | \ - helm upgrade --install "$REPO_NAME" chartmuseum/discord-bot --version 0.2.0 \ + helm upgrade --install "$REPO_NAME" chartmuseum/generic-app --version 0.1.1 \ -f - --set-file scuffbotConfig=config.yaml --set-file sqlInitFile=db/data/init.sql \ --namespace="$REPO_NAME-$ENVIRONMENT" --create-namespace --atomic --timeout=1m --cleanup-on-fail diff --git a/infra/values.yaml b/infra/values.yaml index 5488f93..ed0cec3 100644 --- a/infra/values.yaml +++ b/infra/values.yaml @@ -10,10 +10,10 @@ deployments: environment: BOT_TOKEN: "/secrets/bot-token" CONFIG_FILE: "/config/config.yaml" - DB_DATABASE: SCUFFBOT - DB_HOST: scuffbot-db - DB_PASSWORD_FILE: /secrets/db-password - DB_USER: SCUFFBOT + DB_DATABASE: "SCUFFBOT" + DB_HOST: "service-scuffbot-db" + DB_PASSWORD_FILE: "/secrets/db-password" + DB_USER: "SCUFFBOT" secrets: bot-token: "${BOT_TOKEN}" db-password: "${DB_PASSWORD}" @@ -21,16 +21,16 @@ deployments: statefulSets: - name: "scuffbot-db" image: "mysql:9.2.0" + updateStrategy: "OnDelete" config: fileName: "init.sql" - fileVariable: sqlInitFile - mountPath: /docker-entrypoint-initdb.d/ - containerPort: 3306 + fileVariable: "sqlInitFile" + mountPath: "/docker-entrypoint-initdb.d/" environment: - MYSQL_DATABASE: SCUFFBOT - MYSQL_USER: SCUFFBOT - MYSQL_PASSWORD_FILE: /secrets/db-password - MYSQL_ROOT_PASSWORD_FILE: /secrets/db-root-password + MYSQL_DATABASE: "SCUFFBOT" + MYSQL_USER: "SCUFFBOT" + MYSQL_PASSWORD_FILE: "/secrets/db-password" + MYSQL_ROOT_PASSWORD_FILE: "/secrets/db-root-password" readinessProbe: tcpSocket: port: 3306 @@ -38,8 +38,7 @@ statefulSets: db-password: "${DB_PASSWORD}" db-root-password: "${DB_ROOT_PASSWORD}" service: - type: ClusterIP - headless: true + type: "ClusterIP" port: 3306 volumes: - name: "scuffbot-db" From 94736a30f7b870b05ce2014ac567078418551abd Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Sat, 1 Feb 2025 20:51:34 +1100 Subject: [PATCH 13/26] Revised queue timeout --- README.md | 6 ++++++ src/lib/cogs/SixMans.py | 14 +++----------- src/utils/SixMans.py | 26 ++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 14fed99..73807ea 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,9 @@ If you would like to run locally: - [x] Disable general text chat messaging - [x] Change Team A/B to actual team names - [ ] Add rematch button +- [ ] Disable break out button after clicked + +# Bugs + +- [x] Users get kicked from queue even after finding a match +- [] Users get booted to opposite team if selected then deselected diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index 017eaa9..3800581 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -1,13 +1,13 @@ import asyncio from discord.ext import commands from discord.ui import Button, View, Select, Modal, TextInput -from discord import app_commands, PermissionOverwrite, Object +from discord import PermissionOverwrite from src.lib.bot import config, DB from typing import Any, Callable, Union import discord import logging -from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, QUEUE_TIMEOUT, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty +from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty class SixMansPrompt(View): @@ -425,8 +425,8 @@ class SixMans(commands.Cog): def __init__(self, bot: discord.Client): self.bot = bot self.logger = logging.getLogger(__name__) - self.player_queue = SixMansQueue() self.queue_prompt = QueuePrompt(self, None) + self.player_queue = SixMansQueue(self.queue_prompt) self.category = config["SIX_MAN"]["CATEGORY"] async def cog_load(self): @@ -522,12 +522,10 @@ def __init__(self, ctx: SixMans, message: discord.Message): super().__init__(timeout=None) self.ctx = ctx self.message = message - join_button = Button( label="Join Queue", style=discord.ButtonStyle.green, custom_id="queue_prompt_join") join_button.callback = self.join_callback self.add_item(join_button) - leave_button = Button( label="Leave Queue", style=discord.ButtonStyle.grey, custom_id="queue_prompt_leave") leave_button.callback = self.leave_callback @@ -554,12 +552,6 @@ async def join_callback(self, interaction: discord.Interaction): await self.update_view() lobby_id, party_id = await self.ctx.create_party(party) await self.ctx.start(lobby_id, party_id) - else: - await asyncio.sleep(QUEUE_TIMEOUT * 60) - if interaction.user in self.ctx.player_queue: - self.ctx.player_queue.remove(interaction.user) - await interaction.user.send(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have been removed from the Six Mans queue since a game could not be found in time.", None)) - await self.update_view() async def leave_callback(self, interaction: discord.Interaction): if not interaction.user in self.ctx.player_queue: diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py index 17e97f2..a95f4ac 100644 --- a/src/utils/SixMans.py +++ b/src/utils/SixMans.py @@ -2,6 +2,8 @@ import random from typing import Literal, Union +from datetime import datetime, timezone +from discord.ext import tasks import discord from src.lib.bot import DB @@ -78,20 +80,24 @@ def calculate_winner(self) -> Literal[0, 1, 2]: class SixMansQueue(): - def __init__(self): + def __init__(self, queue_prompt): self.queue = list() + self.queue_prompt = queue_prompt + self.bot = queue_prompt.ctx.bot + self.purge_queue.start() def add(self, player: discord.Member): - self.queue.append(player) + self.queue.append( + {"player": player, "join_time": datetime.now(timezone.utc)}) def remove(self, player: discord.Member): - self.queue.remove(player) + self.queue = list(filter(lambda e: e["player"] != player, self.queue)) def get_party(self): if len(self.queue) == PARTY_SIZE: party = self.queue[:PARTY_SIZE] del self.queue[:PARTY_SIZE] - return party + return list(map(lambda e: e["player"], party)) return [] def __contains__(self, key): @@ -99,3 +105,15 @@ def __contains__(self, key): def __len__(self): return len(self.queue) + + @tasks.loop(minutes=1) + async def purge_queue(self): + for entry in self.queue: + if (datetime.now(timezone.utc) - entry["join_time"]).seconds >= (QUEUE_TIMEOUT * 60): + self.remove(entry["player"]) + await entry["player"].send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have been removed from the Six Mans queue since a game could not be found in time.", None)) + await self.queue_prompt.update_view() + + @purge_queue.before_loop + async def before_purge_queue(self): + await self.bot.wait_until_ready() From a3e67c6e53a3046e2e5c9df062390d4d54940a9c Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Sun, 2 Feb 2025 12:28:50 +1100 Subject: [PATCH 14/26] Fixed bug stopping people from leaving queue --- src/utils/SixMans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py index a95f4ac..a946fd0 100644 --- a/src/utils/SixMans.py +++ b/src/utils/SixMans.py @@ -101,7 +101,7 @@ def get_party(self): return [] def __contains__(self, key): - return key in self.queue + return key in list(map(lambda e: e["player"], self.queue)) def __len__(self): return len(self.queue) From 6d374bc7195e91d858e5479b2d10ec7a6f0340e7 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Wed, 5 Feb 2025 19:50:31 +1100 Subject: [PATCH 15/26] Stop double breakout + stop double select of ones nomination --- README.md | 5 +++-- src/lib/cogs/SixMans.py | 23 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 73807ea..2e71e46 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ If you would like to run locally: - [x] Disable general text chat messaging - [x] Change Team A/B to actual team names - [ ] Add rematch button -- [ ] Disable break out button after clicked +- [x] Disable break out button after clicked # Bugs - [x] Users get kicked from queue even after finding a match -- [] Users get booted to opposite team if selected then deselected +- [COULD NOT REPLICATE] Users get booted to opposite team if selected then deselected +- [x] Users are able to click select ones players multiple times diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index 230d72e..1b99ec0 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -7,7 +7,7 @@ import discord import logging -from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, QUEUE_TIMEOUT, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty +from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty class SixMansPrompt(View): @@ -15,6 +15,7 @@ def __init__(self, bot: discord.Client, party_id: int): super().__init__(timeout=None) self.bot = bot self.message: Union[None | discord.Message] = None + self.broken_out = False self.state = SixMansState.PRE_LOBBY self.game = SixMansMatchType.PRE_MATCH @@ -30,7 +31,7 @@ async def on_voice_state_update(self, member, before, after): return # User joins channel - if str(after.channel.id) == (await self.party.get_details())["VoiceChannelID"] and len(after.channel.members) == PARTY_SIZE: + if str(after.channel.id) == (await self.party.get_details())["VoiceChannelID"] and len(after.channel.members) == PARTY_SIZE - 1: self.state = SixMansState.CHOOSE_CAPTAIN_ONE await self.update_view() @@ -46,7 +47,18 @@ async def interaction_check(self, interaction: discord.Interaction): if interaction.user and interaction.user.id != self.party.captain_two.id: await interaction.response.send_message(f"Only {self.party.captain_two.mention} can do this.", ephemeral=True) return interaction.user and interaction.user.id == self.party.captain_two.id - case SixMansState.CHOOSE_1S_PLAYER | SixMansState.PLAYING | SixMansState.SCORE_UPLOAD | SixMansState.POST_MATCH: + case SixMansState.CHOOSE_1S_PLAYER: + team = DB.field( + "SELECT Team FROM SixManUsers WHERE PartyID = %s AND UserID = %s", self.party.party_id, interaction.user.id) + ones_player = DB.field( + "SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s AND isOnesPlayer IS NOT NULL", self.party.party_id, team) + if interaction.user and interaction.user.id not in [self.party.captain_one.id, self.party.captain_two.id]: + await interaction.response.send_message(f"Only {self.party.captain_one.mention} or {self.party.captain_two.mention} can do this.", ephemeral=True) + elif ones_player != None: + await interaction.response.send_message("You have already nominated a 1s player for your team.", ephemeral=True) + return False + return interaction.user and interaction.user.id in [self.party.captain_one.id, self.party.captain_two.id] + case SixMansState.PLAYING | SixMansState.SCORE_UPLOAD | SixMansState.POST_MATCH: if interaction.user and interaction.user.id not in [self.party.captain_one.id, self.party.captain_two.id]: await interaction.response.send_message(f"Only {self.party.captain_one.mention} or {self.party.captain_two.mention} can do this.", ephemeral=True) return interaction.user and interaction.user.id in [self.party.captain_one.id, self.party.captain_two.id] @@ -79,6 +91,7 @@ def get_match_type(self): return match_type async def create_break_out_rooms(self): + self.broken_out = True lobby_a = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_one.display_name}" lobby_b = f"SixMans #{self.party.lobby_id} - Team {self.party.captain_two.display_name}" @@ -325,6 +338,8 @@ async def choose_button_callback(self, interaction: discord.Interaction): async def break_out_button_callback(self, interaction: discord.Interaction): await interaction.response.defer() + if self.broken_out: + return await self.create_break_out_rooms() self.game = SixMansMatchType.ONE_V_ONE DB.execute("INSERT INTO SixManGames () VALUES ()") @@ -549,8 +564,8 @@ async def join_callback(self, interaction: discord.Interaction): await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.ctx.player_queue)}/{PARTY_SIZE})", None), ephemeral=True) await self.update_view() if ((party := self.ctx.player_queue.get_party())): - await self.update_view() lobby_id, party_id = await self.ctx.create_party(party) + await self.update_view() await self.ctx.start(lobby_id, party_id) async def leave_callback(self, interaction: discord.Interaction): From 684757cefbf91a150f96f536e43d7ee61cbdbb0a Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Tue, 4 Feb 2025 08:04:04 +1100 Subject: [PATCH 16/26] Changed mysql connnector to be pooled connection --- src/lib/bot/__init__.py | 10 ----- src/lib/cogs/SixMans.py | 9 +++-- src/lib/db/__init__.py | 89 ++++++++++++++++++++++------------------- src/utils/SixMans.py | 2 +- 4 files changed, 54 insertions(+), 56 deletions(-) diff --git a/src/lib/bot/__init__.py b/src/lib/bot/__init__.py index d2f8a8e..10ec64d 100644 --- a/src/lib/bot/__init__.py +++ b/src/lib/bot/__init__.py @@ -6,9 +6,7 @@ import logging import discord import yaml -import sys import os -from ..db import DB as SCUFF_DB env_file = find_dotenv(".env.local") load_dotenv(env_file) @@ -16,8 +14,6 @@ with open(os.environ["CONFIG_FILE"], "r", encoding="utf-8") as f: config = yaml.safe_load(f) -DB = SCUFF_DB() - class SCUFFBOT(commands.Bot): @@ -29,12 +25,6 @@ def __init__(self, is_dev): async def setup_hook(self): self.setup_logger() - self.DB = DB - try: - self.DB.connect() - except Exception as e: - self.logger.error(f"Failed to connect to the database: {e}") - sys.exit(1) await self.load_cog_manager() def setup_logger(self): diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index 1b99ec0..7e7dec8 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -2,7 +2,8 @@ from discord.ext import commands from discord.ui import Button, View, Select, Modal, TextInput from discord import PermissionOverwrite -from src.lib.bot import config, DB +from src.lib.db import DB +from src.lib.bot import config from typing import Any, Callable, Union import discord import logging @@ -31,7 +32,7 @@ async def on_voice_state_update(self, member, before, after): return # User joins channel - if str(after.channel.id) == (await self.party.get_details())["VoiceChannelID"] and len(after.channel.members) == PARTY_SIZE - 1: + if str(after.channel.id) == (await self.party.get_details())["VoiceChannelID"] and len(after.channel.members) == PARTY_SIZE: self.state = SixMansState.CHOOSE_CAPTAIN_ONE await self.update_view() @@ -343,7 +344,7 @@ async def break_out_button_callback(self, interaction: discord.Interaction): await self.create_break_out_rooms() self.game = SixMansMatchType.ONE_V_ONE DB.execute("INSERT INTO SixManGames () VALUES ()") - self.party.game_id = DB.field("SELECT LAST_INSERT_ID()") + self.party.game_id = DB.field("SELECT MAX(GameID) FROM SixManGames") DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", self.party.game_id, self.party.party_id) await self.update_view() @@ -499,7 +500,7 @@ async def create_party(self, members): DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) DB.execute("INSERT INTO SixManParty (LobbyID) VALUES (%s)", lobby_id) - party_id = DB.field("SELECT LAST_INSERT_ID()") + party_id = DB.field("SELECT MAX(PartyID) FROM SixManParty") # Invite members for member in members: diff --git a/src/lib/db/__init__.py b/src/lib/db/__init__.py index fb40caf..36d41cb 100644 --- a/src/lib/db/__init__.py +++ b/src/lib/db/__init__.py @@ -1,6 +1,5 @@ import logging -import mysql.connector as mysql -from mysql.connector import errorcode +import mysql.connector.pooling from dotenv import find_dotenv, load_dotenv import os @@ -9,42 +8,50 @@ class DB: - def __init__(self) -> None: - self.logger = logging.getLogger(__name__) - - def connect(self): - with open(os.getenv("DB_PASSWORD_FILE"), "r", encoding="utf-8") as f: - password = f.read().strip() - try: - self.connection = mysql.connect(host=os.getenv("DB_HOST"), user=os.getenv( - "DB_USER"), password=password, database=os.getenv("DB_DATABASE"), autocommit=True) - except mysql.Error as err: - if err.errno == errorcode.ER_ACCESS_DENIED_ERROR: - self.logger.error("[DB] Database credentials incorrect.") - elif err.errno == errorcode.ER_BAD_DB_ERROR: - self.logger.error("[DB] Database does not exist.") - else: - self.logger.error(f"[DB] {err}") - raise err - else: - self.cursor = self.connection.cursor(dictionary=True) - self.logger.info("[DB] Connection established.") - - def execute(self, command, *values): - self.cursor.execute(command, tuple(values)) - - def field(self, command, *values): - self.cursor.execute(command, tuple(values)) - return None if not (data := self.cursor.fetchone()) else list(data.values())[0] - - def row(self, command, *values): - self.cursor.execute(command, tuple(values)) - return self.cursor.fetchone() - - def rows(self, command, *values): - self.cursor.execute(command, tuple(values)) - return self.cursor.fetchall() - - def column(self, command, *values): - self.cursor.execute(command, tuple(values)) - return [list(row.values())[0] for row in self.cursor.fetchall()] + with open(os.getenv("DB_PASSWORD_FILE"), "r", encoding="utf-8") as f: + password = f.read().strip() + pool = mysql.connector.pooling.MySQLConnectionPool(host=os.getenv("DB_HOST"), user=os.getenv( + "DB_USER"), password=password, database=os.getenv("DB_DATABASE"), autocommit=True) + + def get_cursor(): + cnx = DB.pool.get_connection() + cursor = cnx.cursor(dictionary=True) + return (cnx, cursor) + + def close(connection, cursor): + cursor.close() + connection.close() + + def execute(command, *values): + connection, cursor = DB.get_cursor() + cursor.execute(command, tuple(values)) + DB.close(connection, cursor) + + def field(command, *values): + connection, cursor = DB.get_cursor() + cursor.execute(command, tuple(values)) + result = None if not (data := cursor.fetchone() + ) else list(data.values())[0] + DB.close(connection, cursor) + return result + + def row(command, *values): + connection, cursor = DB.get_cursor() + cursor.execute(command, tuple(values)) + result = cursor.fetchone() + DB.close(connection, cursor) + return result + + def rows(command, *values): + connection, cursor = DB.get_cursor() + cursor.execute(command, tuple(values)) + result = cursor.fetchall() + DB.close(connection, cursor) + return result + + def column(command, *values): + connection, cursor = DB.get_cursor() + cursor.execute(command, tuple(values)) + result = [list(row.values())[0] for row in cursor.fetchall()] + DB.close(connection, cursor) + return result diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py index a946fd0..8c3d6dc 100644 --- a/src/utils/SixMans.py +++ b/src/utils/SixMans.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from discord.ext import tasks import discord -from src.lib.bot import DB +from src.lib.db import DB PARTY_SIZE = 6 QUEUE_TIMEOUT = 60 # minutes From 716a12f6a8b6d1b5f3ddff2563bf5c4abc774145 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Mon, 10 Feb 2025 23:10:25 +1100 Subject: [PATCH 17/26] Six mans lobby now is mentioned when a lobby is started --- src/lib/cogs/SixMans.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index 7e7dec8..fecf40d 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -494,20 +494,19 @@ async def create_party(self, members): # Send preliminary starting message message = await text_channel.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"SCUFFBOT is creating the six mans lobby. This message will update once complete.", None)) - - lobby_invite_link = str(await voice_channel.create_invite(reason=f"{lobby_name} created")) - DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) DB.execute("INSERT INTO SixManParty (LobbyID) VALUES (%s)", lobby_id) party_id = DB.field("SELECT MAX(PartyID) FROM SixManParty") # Invite members + lobby_invite_link = str(await voice_channel.create_invite(reason=f"{lobby_name} created")) for member in members: await member.add_roles(lobby_role, reason=f"{member} added to {lobby_name}") await member.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"You have been added into **{lobby_name}**. Failing to join within {LOBBY_TIMEOUT} minutes will drop the match.", None), view=View().add_item(discord.ui.Button(label="Join Six Mans", style=discord.ButtonStyle.link, url=lobby_invite_link))) DB.execute( "INSERT INTO SixManUsers (PartyID, UserID) VALUES (%s, %s)", party_id, member.id) + await text_channel.send(content=lobby_role.mention, delete_after=5) return (lobby_id, party_id) async def start(self, lobby_id: int, party_id: int): From 0957d24433c0f3504cd0998ce7b4079d819bd435 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Tue, 11 Feb 2025 15:34:17 +1100 Subject: [PATCH 18/26] Added administrative close command --- README.md | 10 ++++++++-- src/lib/cogs/SixMans.py | 39 ++++++++++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2e71e46..f56132b 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,15 @@ If you would like to run locally: - [x] Change Team A/B to actual team names - [ ] Add rematch button - [x] Disable break out button after clicked +- [x] Ping the six mans lobby channel when the lobby is created +- [ ] Connect up the MMR system +- [ ] Send players back to the general six mans voice channel after the game has been ended +- [ ] Convert score reporting to win/loss reporting +- [x] Add administrative commands to manipulate six man lobbies + - Added `close` command # Bugs - [x] Users get kicked from queue even after finding a match -- [COULD NOT REPLICATE] Users get booted to opposite team if selected then deselected -- [x] Users are able to click select ones players multiple times +- [ ] When captains select players after being deselected on the "choose players" prompt, the selected players get booted to the opposite team [COULD NOT REPLICATE] +- [x] Captains are able to click select 1s players multiple times resulting in multiple 1s players in the party per team diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index fecf40d..5fde668 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -1,7 +1,7 @@ import asyncio from discord.ext import commands from discord.ui import Button, View, Select, Modal, TextInput -from discord import PermissionOverwrite +from discord import PermissionOverwrite, app_commands from src.lib.db import DB from src.lib.bot import config from typing import Any, Callable, Union @@ -12,16 +12,17 @@ class SixMansPrompt(View): - def __init__(self, bot: discord.Client, party_id: int): + def __init__(self, ctx, party_id: int): super().__init__(timeout=None) - self.bot = bot + self.ctx = ctx + self.bot = ctx.bot self.message: Union[None | discord.Message] = None self.broken_out = False self.state = SixMansState.PRE_LOBBY self.game = SixMansMatchType.PRE_MATCH - self.party = SixMansParty(bot, party_id) + self.party = SixMansParty(self.bot, party_id) self.bot.event(self.on_voice_state_update) @commands.Cog.listener() @@ -80,6 +81,7 @@ async def delete_lobby(self): await (self.bot.get_channel(int(lobby_details["VoiceChannelB"]))).delete(reason=f"{lobby_name} finished/cancelled") DB.execute("DELETE FROM SixManLobby WHERE LobbyID = %s", self.party.lobby_id) + del self.ctx.lobbies[str(self.party.lobby_id)] def get_match_type(self): match self.game: @@ -437,6 +439,29 @@ async def on_submit(self, interaction: discord.Interaction): await self.ctx.update_view() +class SixMansCommands(app_commands.Group): + def __init__(self, ctx): + super().__init__(name="sixmans", description="Manage six man lobbies") + self.ctx = ctx + + @app_commands.command(name="close", description="Closes an existing six mans lobby.") + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.describe( + lobby_id="The six mans lobby number to close." + ) + async def close(self, interaction: discord.Interaction, lobby_id: str): + await interaction.response.defer(ephemeral=True) + if lobby_id in self.ctx.lobbies: + await self.ctx.lobbies[lobby_id].delete_lobby() + await interaction.followup.send(embed=self.ctx.bot.create_embed("SCUFFBOT SIX MANS", f"Successfully closed SixMans #{lobby_id}", None), ephemeral=True) + else: + await interaction.followup.send(embed=self.ctx.bot.create_embed("SCUFFBOT SIX MANS", f"SixMans #{lobby_id} does not exist", None), ephemeral=True) + + @close.autocomplete("lobby_id") + async def autocomplete_callback(self, _: discord.Interaction, current: str): + return [app_commands.Choice(name=lobby_id, value=lobby_id) for lobby_id in self.ctx.lobbies] + + class SixMans(commands.Cog): def __init__(self, bot: discord.Client): self.bot = bot @@ -445,6 +470,8 @@ def __init__(self, bot: discord.Client): self.player_queue = SixMansQueue(self.queue_prompt) self.category = config["SIX_MAN"]["CATEGORY"] + self.lobbies = dict() + async def cog_load(self): asyncio.create_task(self.create_queue_prompt()) self.logger.info(f"[COG] Loaded {self.__class__.__name__}") @@ -515,8 +542,9 @@ async def start(self, lobby_id: int, party_id: int): voice_channel = self.bot.get_channel(int(DB.field( "SELECT VoiceChannelID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) message = await text_channel.fetch_message(int(DB.field("SELECT MessageID FROM SixManLobby WHERE LobbyID = %s", lobby_id))) - view = SixMansPrompt(self.bot, party_id) + view = SixMansPrompt(self, party_id) view.message = message + self.lobbies[str(lobby_id)] = view await view.update_view() # Delete lobby if not all people have joined @@ -528,6 +556,7 @@ async def start(self, lobby_id: int, party_id: int): async def setup(bot): ctx = SixMans(bot) bot.add_view(ctx.queue_prompt) + bot.tree.add_command(SixMansCommands(ctx)) await bot.add_cog(ctx) From 0046df73dc4ffb3018767886843fab9839949dec Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Tue, 18 Feb 2025 16:59:42 +1100 Subject: [PATCH 19/26] fix: prompt now doesn't reset after a user reconnects to the call --- src/lib/cogs/SixMans.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index 5fde668..f777d60 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -33,7 +33,7 @@ async def on_voice_state_update(self, member, before, after): return # User joins channel - if str(after.channel.id) == (await self.party.get_details())["VoiceChannelID"] and len(after.channel.members) == PARTY_SIZE: + if str(after.channel.id) == (await self.party.get_details())["VoiceChannelID"] and self.state == SixMansState.PRE_LOBBY and len(after.channel.members) == PARTY_SIZE: self.state = SixMansState.CHOOSE_CAPTAIN_ONE await self.update_view() @@ -190,7 +190,7 @@ def generate_embed(self): f"• **[{self.generate_flag_str(member)}]** {member.display_name}" if member else f"• {member}" for member in team_two_players] match self.state: case SixMansState.PRE_LOBBY: - description = f"Hello! Welcome to {self.bot.user.mention} Six Mans!\n\nSix Mans will start once all players have joined the voice channel. Otherwise, if all players have not connected within {LOBBY_TIMEOUT} minutes, the lobby will automatically be deleted.\n\n" + description = f"Hello! Welcome to {self.bot.user.mention} Six Mans!\n\nSix Mans will start once all players have joined the voice channel. If this message does not update after all six players have already connected, try reconnecting to the call.\n\nOtherwise, if all players have not connected within {LOBBY_TIMEOUT} minutes, the lobby will automatically be deleted." embed: discord.Embed = self.bot.create_embed( f"ScuffBot Six Mans #{self.party.lobby_id}", description, None) embed.set_footer( @@ -520,7 +520,7 @@ async def create_party(self, members): text_channel = await guild.create_text_channel(name=f"six-mans-{lobby_id}", overwrites=text_perms, category=self.bot.get_channel(self.category), reason=f"{lobby_name} created") # Send preliminary starting message - message = await text_channel.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"SCUFFBOT is creating the six mans lobby. This message will update once complete.", None)) + message = await text_channel.send(embed=self.bot.create_embed("SCUFFBOT SIX MANS", f"SCUFFBOT is creating the six mans lobby. Please do not join the call until the six mans lobby is ready. This message will update once complete.", None)) DB.execute("INSERT INTO SixManLobby (LobbyID, MessageID, VoiceChannelID, TextChannelID, RoleID) VALUES (%s, %s, %s, %s, %s)", lobby_id, message.id, voice_channel.id, text_channel.id, lobby_role.id) DB.execute("INSERT INTO SixManParty (LobbyID) VALUES (%s)", lobby_id) From ce21b89ae132542c7070ee3d7ed2ab1bd810d0a9 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Thu, 20 Feb 2025 22:47:29 +1100 Subject: [PATCH 20/26] fix: condition where more than 6 players would not trigger a lobby --- src/utils/SixMans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py index 8c3d6dc..0da5b2c 100644 --- a/src/utils/SixMans.py +++ b/src/utils/SixMans.py @@ -94,7 +94,7 @@ def remove(self, player: discord.Member): self.queue = list(filter(lambda e: e["player"] != player, self.queue)) def get_party(self): - if len(self.queue) == PARTY_SIZE: + if len(self.queue) >= PARTY_SIZE: party = self.queue[:PARTY_SIZE] del self.queue[:PARTY_SIZE] return list(map(lambda e: e["player"], party)) From 44c766573ab36d09abd20163eed07aca03a53903 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Tue, 4 Feb 2025 00:46:40 +1100 Subject: [PATCH 21/26] Added score inference --- README.md | 2 + src/lib/bot/__init__.py | 2 +- src/lib/cogs/SixMans.py | 235 ++++++++++++++++++++++++++-------------- src/utils/SixMans.py | 5 +- 4 files changed, 161 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index f56132b..e9278b9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ If you would like to run locally: - [ ] Convert score reporting to win/loss reporting - [x] Add administrative commands to manipulate six man lobbies - Added `close` command +- [ ] Have bot automatically infer scores +- [ ] Change score report system to a team win report # Bugs diff --git a/src/lib/bot/__init__.py b/src/lib/bot/__init__.py index 10ec64d..5552125 100644 --- a/src/lib/bot/__init__.py +++ b/src/lib/bot/__init__.py @@ -18,7 +18,7 @@ class SCUFFBOT(commands.Bot): def __init__(self, is_dev): - super().__init__(command_prefix="!", + super().__init__(command_prefix="/", owner_id=169402073404669952, intents=discord.Intents.all()) self.is_dev = is_dev self.mode = "DEVELOPMENT" if is_dev else "PRODUCTION" diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index f777d60..836b408 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -1,6 +1,7 @@ import asyncio +import re from discord.ext import commands -from discord.ui import Button, View, Select, Modal, TextInput +from discord.ui import Button, View, Select from discord import PermissionOverwrite, app_commands from src.lib.db import DB from src.lib.bot import config @@ -10,6 +11,8 @@ from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty +ROCKET_LEAGUE_APP_ID = 356877880938070016 + class SixMansPrompt(View): def __init__(self, ctx, party_id: int): @@ -23,9 +26,71 @@ def __init__(self, ctx, party_id: int): self.game = SixMansMatchType.PRE_MATCH self.party = SixMansParty(self.bot, party_id) - self.bot.event(self.on_voice_state_update) + self.scores = {"1v1": (None, None), "2v2": ( + None, None), "3v3": (None, None)} + self.last_remaining = None + + self.bot.add_listener(self.on_voice_state_update) + self.bot.add_listener(self.on_presence_update) + + async def on_presence_update(self, _, member: discord.Member): + if self.state == SixMansState.PLAYING and member.id in list(map(lambda p: p.id, self.party.get_players())) and ROCKET_LEAGUE_APP_ID in (list(map(lambda a: a.application_id, list(filter(lambda a: isinstance(a, discord.Activity), member.activities))))): + print(f"Playing {self.get_match_type()}", flush=True) + all_players = DB.rows( + "SELECT UserID, Type, Team, isOnesPlayer FROM SixManUsers WHERE PartyID = %s", self.party.party_id) + + match self.game: + case SixMansMatchType.ONE_V_ONE: + players = [(int(row["UserID"]), int(row["Team"])) + for row in all_players if row["isOnesPlayer"] == 1] + case SixMansMatchType.TWO_V_TWO: + players = [(int(row["UserID"]), int(row["Team"])) + for row in all_players if row["isOnesPlayer"] == 0] + case SixMansMatchType.THREE_V_THREE: + players = [(int(row["UserID"]), int(row["Team"])) + for row in all_players] + + activity = next(filter(lambda a: isinstance(a, discord.Activity) + and a.application_id == ROCKET_LEAGUE_APP_ID, member.activities), None) + if "Overtime" in activity.state: + match = re.search( + r"^Private (?P[0-9]+):(?P[0-9]+) \[Overtime (?P[0-9]+):(?P[0-9]+)\]$", activity.state) + remaining_seconds = 0 + else: + match = re.search( + r"^Private (?P[0-9]+):(?P[0-9]+) \[(?P[0-9]+):(?P[0-9]+) remaining\]$", activity.state) + remaining_seconds = (int(match.group("minutes_left")) + * 60) + int(match.group("seconds_left")) + + if member.id in list(map(lambda p: p[0], players)): + match_type = self.get_match_type() + match_score = [None, None] + team_index = next(filter(lambda p: p[0] == member.id, players))[ + 1] - 1 + match_score[team_index] = int(match.group("own_score")) + match_score[1 - + team_index] = int(match.group("opposition_score")) + print( + f"Inferred score {tuple(match_score)} from {member}", flush=True) + if any(match_score): + self.scores[match_type] = tuple(match_score) + print( + f"Updating remaining time of {remaining_seconds} from {member}", flush=True) + # TODO: Game does not advance upon finishing 3v3 + if (self.last_remaining == 0 and tuple(match_score) != self.scores[self.get_match_type()]) or (self.last_remaining != None and remaining_seconds > self.last_remaining): + match self.game: + case SixMansMatchType.ONE_V_ONE: + self.game = SixMansMatchType.TWO_V_TWO + self.state = SixMansState.PLAYING + case SixMansMatchType.TWO_V_TWO: + self.game = SixMansMatchType.THREE_V_THREE + self.state = SixMansState.PLAYING + case SixMansMatchType.THREE_V_THREE: + self.state = SixMansState.SCORE_VALIDATION + self.last_remaining = None + return await self.update_view() + self.last_remaining = remaining_seconds - @commands.Cog.listener() async def on_voice_state_update(self, member, before, after): if before.channel is not None and after.channel is not None and before.channel == after.channel: return @@ -60,7 +125,7 @@ async def interaction_check(self, interaction: discord.Interaction): await interaction.response.send_message("You have already nominated a 1s player for your team.", ephemeral=True) return False return interaction.user and interaction.user.id in [self.party.captain_one.id, self.party.captain_two.id] - case SixMansState.PLAYING | SixMansState.SCORE_UPLOAD | SixMansState.POST_MATCH: + case SixMansState.PLAYING | SixMansState.SCORE_VALIDATION | SixMansState.POST_MATCH: if interaction.user and interaction.user.id not in [self.party.captain_one.id, self.party.captain_two.id]: await interaction.response.send_message(f"Only {self.party.captain_one.mention} or {self.party.captain_two.mention} can do this.", ephemeral=True) return interaction.user and interaction.user.id in [self.party.captain_one.id, self.party.captain_two.id] @@ -137,21 +202,15 @@ def generate_flag_str(self, member: discord.Member): return ", ".join(flags) def generate_match_summary(self) -> str: - scores = [0 if x is None else x for x in DB.row( - "SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.party.game_id).values()] - team_a = ['W' if x else '-' for x in [scores[i] > scores[i + 1] - for i in range(0, len(scores), 2)]] - team_b = ['W' if x else '-' for x in [scores[i+1] > scores[i] - for i in range(0, len(scores), 2)]] name_1 = f"Team {self.party.captain_one.display_name}" name_2 = f"Team {self.party.captain_two.display_name}" max_len = max(len(name_1), len(name_2)) return f"""``` | {' '* (max_len)} | 1v1 | 2v2 | 3v3 | -| {'-'* (max_len)} |-----|-----|-----| -| {name_1:<{max_len}} | {team_a[0]} | {team_a[1]} | {team_a[2]} | -| {name_2:<{max_len}} | {team_b[0]} | {team_b[1]} | {team_b[2]} |```""" +| {'-'* (max_len)} | {'-'* (3)} | {'-'* (3)} | {'-'* (3)} | +| {name_1:<{max_len}} | {'-' if self.scores['1v1'][0] == None else self.scores['1v1'][0]:^3} | {'-' if self.scores['2v2'][0] == None else self.scores['2v2'][0]:^3} | {'-' if self.scores['3v3'][0] == None else self.scores['3v3'][0]:^3} | +| {name_2:<{max_len}} | {'-' if self.scores['1v1'][1] == None else self.scores['1v1'][1]:^3} | {'-' if self.scores['2v2'][1] == None else self.scores['2v2'][1]:^3} | {'-' if self.scores['3v3'][1] == None else self.scores['3v3'][1]:^3} |```""" def generate_match_composition(self) -> str: players = DB.rows( @@ -243,29 +302,32 @@ def generate_embed(self): description = f"Now that we have our 1s players sorted, we are ready to get the ball rolling... *pun intended :D*\n\nAmongst yourselves, please nominate a player to host a private match. Whether you create separate 1v1, 2v2, and 3v3 matches or create a single 3v3 match and re-use it for all matches is entirely up to you.\n\nFrom this point onwards, if you would like to see the entire team composition, click the **View team composition** button below.\n\nThe next screen will show you a breakdown of the matches with specific team compositions for each match.\n\nWhen you are ready to move on, click the **Break out** button below and you will be moved automatically into separate channels. May the best team win!" case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: match_type = self.get_match_type() - description = f"You are now playing the {match_type} match. **Don't forget to come back here before your next game starts.**\n\n{self.generate_match_summary()}\n{self.generate_match_composition()}\n\nOnce you have finished your {match_type} match, click on the **Finish {match_type}** button below. Best of luck!" - case SixMansState.SCORE_UPLOAD: - match_type = self.get_match_type() - - team_a_reported_score = self.party.reported_scores[self.party.captain_one.id][match_type] - team_b_reported_score = self.party.reported_scores[self.party.captain_two.id][match_type] - - if team_a_reported_score == (None, None) and team_b_reported_score == (None, None): - waiting_message = "both team captains to upload the match score.**" - elif team_a_reported_score == (None, None) and team_b_reported_score != (None, None): - waiting_message = f"{self.party.captain_one.mention} to upload the match score.**" - elif team_a_reported_score != (None, None) and team_b_reported_score == (None, None): - waiting_message = f"{self.party.captain_two.mention} to upload the match score.**" + # description = f"{self.generate_match_summary()}\n**You are now playing the {match_type} match.** All match scores will automatically populate where possible. If scores cannot be determined, a '-' will be displayed and you may amend the scores at the very end. Best of luck!\n\n{self.generate_match_composition()}" + description = f"{self.generate_match_summary()}\n**You should now be in game playing!** All match scores will automatically populate where possible. If scores cannot be determined, a '-' will be displayed and you may amend the scores at the very end. Best of luck!\n\n{self.generate_match_composition()}\n\nOnce you have played all your matches, press the **Go to Match Reporting** button below." + case SixMansState.SCORE_VALIDATION: + team_a_scores = list( + self.party.reported_scores[self.party.captain_one.id].values()) + team_b_scores = list( + self.party.reported_scores[self.party.captain_two.id].values()) + + if all(map(lambda s: s == (None, None), team_a_scores)) and all(map(lambda s: s == (None, None), team_b_scores)): + waiting_message = "both team captains to review the match results.**" + elif all(map(lambda s: s == (None, None), team_a_scores)) and not all(map(lambda s: s == (None, None), team_b_scores)): + waiting_message = f"{self.party.captain_one.mention} to review the match results.**" + elif not all(map(lambda s: s == (None, None), team_a_scores)) and all(map(lambda s: s == (None, None), team_b_scores)): + waiting_message = f"{self.party.captain_two.mention} to review the match results.**" else: - waiting_message = f"a resolution to a discrepancy between reported match scores.**" + waiting_message = f"a resolution to a discrepancy between reported match results.**" - description = f"Now that the {match_type} match is complete, both team captains must upload the score of the match.\n\nThe score will not be registered if there is a score discrepancy between both teams.\n\n**Currently waiting on {waiting_message}" + description = f"Here is a chance to review the match outcomes.\n\nIf the inferred scores did not look right, please report the correct winning teams using the button below. The report will not be registered if there is a discrepancy between both teams.\n\n**Currently waiting on {waiting_message}" case SixMansState.POST_MATCH: match self.party.calculate_winner(): case 1: winner = self.party.captain_one.display_name case 2: winner = self.party.captain_two.display_name + case _: + winner = "N/A" description = f"### Congratulations Team {winner}!\nYou have won the six mans scrims!\n\nPress the **End Game** button below when you are done. Thanks for playing!" case _: description = "Uh oh! Something has gone wrong." @@ -301,18 +363,18 @@ def update_options(self): self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: - match_label = self.get_match_type() + # match_label = self.get_match_type() self.add_button( - f"Finish {match_label}", discord.ButtonStyle.blurple, self.finish_button_callback) + f"Go to Match Reporting", discord.ButtonStyle.blurple, self.go_to_match_reporting_callback) self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") - self.add_button( - f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) - case SixMansState.SCORE_UPLOAD: + # self.add_button( + # f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) + case SixMansState.SCORE_VALIDATION: self.add_button( - "Upload Scores", discord.ButtonStyle.blurple, self.upload_scores_callback) - self.add_button("View team composition", discord.ButtonStyle.grey, - self.team_composition_callback, custom_id="team_comp") + "Review Matches", discord.ButtonStyle.blurple, self.send_report_view) + # self.add_button("View team composition", discord.ButtonStyle.grey, + # self.team_composition_callback, custom_id="team_comp") case SixMansState.POST_MATCH: self.add_button("End Game", discord.ButtonStyle.red, self.cancel_button_callback) @@ -363,15 +425,14 @@ async def surrender_button_callback(self, interaction: discord.Interaction): await self.update_view() return await interaction.response.send_message(f"You have surrendered the {match_type} match.", ephemeral=True) - async def upload_scores_callback(self, interaction: discord.Interaction): - match_type = self.get_match_type() - await interaction.response.send_modal(ScoreUpload(self, match_type)) - - async def finish_button_callback(self, interaction: discord.Interaction): + async def go_to_match_reporting_callback(self, interaction: discord.Interaction): await interaction.response.defer() - self.state = SixMansState.SCORE_UPLOAD + self.state = SixMansState.SCORE_VALIDATION await self.update_view() + async def send_report_view(self, interaction: discord.Interaction): + await interaction.response.send_message(view=ReportMatches(self), ephemeral=True) + async def team_composition_callback(self, interaction: discord.Interaction): team_one_players = self.party.get_players(1) team_two_players = self.party.get_players(2) @@ -390,52 +451,64 @@ async def team_composition_callback(self, interaction: discord.Interaction): await interaction.response.send_message(embed=embed, ephemeral=True) -class ScoreUpload(Modal): - score_a = TextInput( - label=f'Team A Score') - score_b = TextInput( - label=f'Team B Score') - - def __init__(self, ctx: SixMansPrompt, title: str) -> None: - super().__init__(title=title) - self.ctx = ctx - self.score_a.label = f'Team {self.ctx.party.captain_one.display_name} Score' - self.score_b.label = f'Team {self.ctx.party.captain_two.display_name} Score' +class TeamDropdown(Select): + def __init__(self, teams: list[str], match_type: str, winner: int = -1): + self.match_type = match_type + options = [discord.SelectOption( + label=team, value=idx, default=winner == idx) for idx, team in enumerate(teams)] + super().__init__(placeholder=f"Select the winning team for the {match_type} series", + min_values=1, max_values=1, options=options) - async def on_submit(self, interaction: discord.Interaction): + async def callback(self, interaction: discord.Interaction): await interaction.response.defer() - match_type = self.ctx.get_match_type() - if self.score_a.value == self.score_b.value: - return await interaction.followup.send("The team's score cannot be identical. Please re-input your values.", ephemeral=True) - elif (not self.score_a.value.isnumeric()) or (not self.score_b.value.isnumeric()): - return await interaction.followup.send("One or more of your scores is not valid. Please ensure they are non-negative numbers.", ephemeral=True) - else: - self.ctx.party.reported_scores[interaction.user.id][match_type] = ( - int(self.score_a.value), int(self.score_b.value)) - # Check if both scores are present and compare - team_a_reported_score = self.ctx.party.reported_scores[ - self.ctx.party.captain_one.id][match_type] - team_b_reported_score = self.ctx.party.reported_scores[ - self.ctx.party.captain_two.id][match_type] +class ReportMatches(View): - if team_a_reported_score == team_b_reported_score: + def __init__(self, ctx: SixMansPrompt) -> None: + super().__init__(timeout=None) + self.ctx = ctx + self.selects = list() + + for match in self.ctx.scores.items(): + teams = [f"Team {self.ctx.party.captain_one.display_name}", + f"Team {self.ctx.party.captain_two.display_name}"] + winner = (0 if match[1][0] > match[1][1] else 1) if all( + map(lambda s: s is not None, match[1])) else -1 + select = TeamDropdown(teams, match[0], winner) + self.add_item(select) + self.selects.append(select) + + self.submit_button = discord.ui.Button( + label="Submit", style=discord.ButtonStyle.green) + self.submit_button.callback = self.submit_button_callback + self.add_item(self.submit_button) + + async def submit_button_callback(self, interaction: discord.Interaction): + if any(map(lambda s: len(s.values) == 0 and all(map(lambda o: o.default == False, s.options)), self.selects)): + return await interaction.response.send_message("You must report all series scores first before submitting.\n\nIf it appears like you have reported all 3 series outcomes, try clicking **Review Matches** again and after interacting with a single dropdown, wait until the blinking dots next to the dropdown disappears before moving onto the next dropdown.", ephemeral=True) + for select in self.selects: + match_type = select.match_type + + score = [0, 0] + if select.values: + score[int(select.values[0])] = 1 + else: + score[int(next(filter(lambda o: o.default == + True, select.options)).value)] = 1 + self.ctx.party.reported_scores[interaction.user.id][match_type] = tuple( + score) + + await interaction.response.send_message("Your match report has been submitted successfully. Thanks!", ephemeral=True) + # Check if both scores are present and compare + scores = list(self.ctx.party.reported_scores.values()) + if scores[0] == scores[1]: + for select in self.selects: + match_type = select.match_type # Both scores set and equal DB.execute(f"UPDATE SixManGames SET {match_type}_A = %s, {match_type}_B = %s WHERE GameID = %s", - team_a_reported_score[0], team_b_reported_score[1], self.ctx.party.game_id) - match self.ctx.game: - case SixMansMatchType.ONE_V_ONE: - self.ctx.game = SixMansMatchType.TWO_V_TWO - self.ctx.state = SixMansState.PLAYING - case SixMansMatchType.TWO_V_TWO: - if self.ctx.party.calculate_winner() == 0: - self.ctx.game = SixMansMatchType.THREE_V_THREE - self.ctx.state = SixMansState.PLAYING - else: - self.ctx.state = SixMansState.POST_MATCH - case SixMansMatchType.THREE_V_THREE: - self.ctx.state = SixMansState.POST_MATCH + scores[0][match_type][0], scores[1][match_type][1], self.ctx.party.game_id) + self.ctx.state = SixMansState.POST_MATCH await self.ctx.update_view() diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py index 0da5b2c..a883d07 100644 --- a/src/utils/SixMans.py +++ b/src/utils/SixMans.py @@ -18,7 +18,7 @@ class SixMansState(Enum): CHOOSE_CAPTAIN_TWO = 2 CHOOSE_1S_PLAYER = 3 PLAYING = 4 - SCORE_UPLOAD = 5 + SCORE_VALIDATION = 5 POST_MATCH = 6 @@ -48,6 +48,9 @@ async def get_details(self): return DB.row("SELECT * FROM SixManLobby WHERE LobbyID = %s", self.lobby_id) def get_players(self, team: Literal[None, 1, 2] = None): + if team == None: + return [self.bot.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s", self.party_id)] + return [self.bot.get_user(int(user_id)) for user_id in DB.column("SELECT UserID FROM SixManUsers WHERE PartyID = %s AND Team = %s", self.party_id, 0 if team == None else team)] def generate_captains(self): From 0c588981e0be584330643cfb96c11e7afd0d0201 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Sun, 23 Feb 2025 21:15:38 +1100 Subject: [PATCH 22/26] Add support for best of 3s for 3s gamemode --- README.md | 9 +- db/data/init.sql | 8 +- src/lib/cogs/SixMans.py | 230 +++++++++++++++++++++++++++------------- src/utils/SixMans.py | 22 ++-- 4 files changed, 181 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index e9278b9..73ab660 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@ If you would like to run locally: 4. Copy [/db/.env.template](./db/.env.template) to `db/.env`, and adjust values as necessary. 5. Run the bot with `docker compose up -d --build`. +If you have something to suggest, whether it is feedback or a bug report, put it in the thread below. As I acknowledge your feedback, I will update this list. This is what I have so far: + # Feedback - [x] Implement a queue timeout, perhaps 45-60mins? - [x] Notify the channel when a player has joined the queue -- [ ] Have options to do a 1s (best of 1), 2s (best of 1), 3s (best of 3) and/or have the option to configure the game to be a best of 1 or best of 3 - [ ] Have request to spectate six mans matches - [x] Wait for all 6 people to join call before starting otherwise cancel after 5 mins -- [ ] Ability to substitute players into the game? - [ ] Incorporate personal stats page - [x] Disable general text chat messaging - [x] Change Team A/B to actual team names @@ -33,14 +33,11 @@ If you would like to run locally: - [x] Ping the six mans lobby channel when the lobby is created - [ ] Connect up the MMR system - [ ] Send players back to the general six mans voice channel after the game has been ended -- [ ] Convert score reporting to win/loss reporting +- [x] Convert score reporting to win/loss reporting - [x] Add administrative commands to manipulate six man lobbies - Added `close` command -- [ ] Have bot automatically infer scores -- [ ] Change score report system to a team win report # Bugs - [x] Users get kicked from queue even after finding a match -- [ ] When captains select players after being deselected on the "choose players" prompt, the selected players get booted to the opposite team [COULD NOT REPLICATE] - [x] Captains are able to click select 1s players multiple times resulting in multiple 1s players in the party per team diff --git a/db/data/init.sql b/db/data/init.sql index fe11b6d..89b1f44 100644 --- a/db/data/init.sql +++ b/db/data/init.sql @@ -26,8 +26,12 @@ CREATE TABLE `1v1_B` INTEGER DEFAULT NULL, `2v2_A` INTEGER DEFAULT NULL, `2v2_B` INTEGER DEFAULT NULL, - `3v3_A` INTEGER DEFAULT NULL, - `3v3_B` INTEGER DEFAULT NULL, + `3v3_A_A` INTEGER DEFAULT NULL, + `3v3_A_B` INTEGER DEFAULT NULL, + `3v3_B_A` INTEGER DEFAULT NULL, + `3v3_B_B` INTEGER DEFAULT NULL, + `3v3_C_A` INTEGER DEFAULT NULL, + `3v3_C_B` INTEGER DEFAULT NULL, PRIMARY KEY (`GameID`) ); diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index 836b408..beac03c 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -11,8 +11,11 @@ from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty -ROCKET_LEAGUE_APP_ID = 356877880938070016 +BAKKES_APP_ID = 356877880938070016 +DISCORD_RPC_APP_ID = 562098317547405322 +ROCKET_LEAGUE_APP_IDS = [BAKKES_APP_ID] +TIME_DIFFERENT_THRESHOLD = 10 class SixMansPrompt(View): def __init__(self, ctx, party_id: int): @@ -27,14 +30,14 @@ def __init__(self, ctx, party_id: int): self.party = SixMansParty(self.bot, party_id) self.scores = {"1v1": (None, None), "2v2": ( - None, None), "3v3": (None, None)} + None, None), "3v3_A": (None, None), "3v3_B": (None, None), "3v3_C": (None, None)} self.last_remaining = None self.bot.add_listener(self.on_voice_state_update) self.bot.add_listener(self.on_presence_update) async def on_presence_update(self, _, member: discord.Member): - if self.state == SixMansState.PLAYING and member.id in list(map(lambda p: p.id, self.party.get_players())) and ROCKET_LEAGUE_APP_ID in (list(map(lambda a: a.application_id, list(filter(lambda a: isinstance(a, discord.Activity), member.activities))))): + if self.state == SixMansState.PLAYING and member.id in list(map(lambda p: p.id, self.party.get_players())) and set(map(lambda a: a.application_id, list(filter(lambda a: isinstance(a, discord.Activity), member.activities)))).intersection(set(ROCKET_LEAGUE_APP_IDS)): print(f"Playing {self.get_match_type()}", flush=True) all_players = DB.rows( "SELECT UserID, Type, Team, isOnesPlayer FROM SixManUsers WHERE PartyID = %s", self.party.party_id) @@ -46,21 +49,35 @@ async def on_presence_update(self, _, member: discord.Member): case SixMansMatchType.TWO_V_TWO: players = [(int(row["UserID"]), int(row["Team"])) for row in all_players if row["isOnesPlayer"] == 0] - case SixMansMatchType.THREE_V_THREE: + case SixMansMatchType.THREE_V_THREE_A | SixMansMatchType.THREE_V_THREE_B | SixMansMatchType.THREE_V_THREE_C: players = [(int(row["UserID"]), int(row["Team"])) for row in all_players] activity = next(filter(lambda a: isinstance(a, discord.Activity) - and a.application_id == ROCKET_LEAGUE_APP_ID, member.activities), None) - if "Overtime" in activity.state: - match = re.search( - r"^Private (?P[0-9]+):(?P[0-9]+) \[Overtime (?P[0-9]+):(?P[0-9]+)\]$", activity.state) - remaining_seconds = 0 + and a.application_id in ROCKET_LEAGUE_APP_IDS, member.activities), None) + if activity.application_id == BAKKES_APP_ID: + if "Overtime" in activity.state: + match = re.search( + r"^Private (?P[0-9]+):(?P[0-9]+) \[Overtime (?P[0-9]+):(?P[0-9]+)\]$", activity.state) + remaining_seconds = 0 + else: + match = re.search( + r"^Private (?P[0-9]+):(?P[0-9]+) \[(?P[0-9]+):(?P[0-9]+) remaining\]$", activity.state) + remaining_seconds = (int(match.group("minutes_left")) + * 60) + int(match.group("seconds_left")) else: - match = re.search( - r"^Private (?P[0-9]+):(?P[0-9]+) \[(?P[0-9]+):(?P[0-9]+) remaining\]$", activity.state) - remaining_seconds = (int(match.group("minutes_left")) - * 60) + int(match.group("seconds_left")) + if match := re.search( + r"^(?:Won|Lost) \((?P[0-9]+) - (?P[0-9]+)\)$", activity.state): + remaining_seconds = -1 + elif "(OT)" in activity.state: + match = re.search( + r"^(?:Winning|Tied|Losing) (?P[0-9]+) - (?P[0-9]+) \(OT\)$", activity.state) + remaining_seconds = 0 + else: + match = re.search( + r"^(?:Winning|Tied|Losing) (?P[0-9]+) - (?P[0-9]+)$", activity.state) + remaining_seconds = self.last_remaining + if member.id in list(map(lambda p: p[0], players)): match_type = self.get_match_type() @@ -70,22 +87,26 @@ async def on_presence_update(self, _, member: discord.Member): match_score[team_index] = int(match.group("own_score")) match_score[1 - team_index] = int(match.group("opposition_score")) - print( - f"Inferred score {tuple(match_score)} from {member}", flush=True) - if any(match_score): + if any(match_score) and self.scores[match_type] != tuple(match_score): + print( + f"Inferred score {tuple(match_score)} from {member}", flush=True) self.scores[match_type] = tuple(match_score) - print( - f"Updating remaining time of {remaining_seconds} from {member}", flush=True) - # TODO: Game does not advance upon finishing 3v3 - if (self.last_remaining == 0 and tuple(match_score) != self.scores[self.get_match_type()]) or (self.last_remaining != None and remaining_seconds > self.last_remaining): + await self.update_view() + if remaining_seconds == -1 or (self.last_remaining != None and remaining_seconds - self.last_remaining > TIME_DIFFERENT_THRESHOLD): match self.game: case SixMansMatchType.ONE_V_ONE: self.game = SixMansMatchType.TWO_V_TWO self.state = SixMansState.PLAYING case SixMansMatchType.TWO_V_TWO: - self.game = SixMansMatchType.THREE_V_THREE + self.game = SixMansMatchType.THREE_V_THREE_A + self.state = SixMansState.PLAYING + case SixMansMatchType.THREE_V_THREE_A: + self.game = SixMansMatchType.THREE_V_THREE_B self.state = SixMansState.PLAYING - case SixMansMatchType.THREE_V_THREE: + case SixMansMatchType.THREE_V_THREE_B: + self.game = SixMansMatchType.THREE_V_THREE_C + self.state = SixMansState.PLAYING + case SixMansMatchType.THREE_V_THREE_C: self.state = SixMansState.SCORE_VALIDATION self.last_remaining = None return await self.update_view() @@ -154,8 +175,12 @@ def get_match_type(self): match_type = "1v1" case SixMansMatchType.TWO_V_TWO: match_type = "2v2" - case SixMansMatchType.THREE_V_THREE: - match_type = "3v3" + case SixMansMatchType.THREE_V_THREE_A: + match_type = "3v3_A" + case SixMansMatchType.THREE_V_THREE_B: + match_type = "3v3_B" + case SixMansMatchType.THREE_V_THREE_C: + match_type = "3v3_C" return match_type async def create_break_out_rooms(self): @@ -168,9 +193,9 @@ async def create_break_out_rooms(self): guild: discord.Guild = self.message.guild voice_channel_a_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True, connect=True, view_channel=True)), team_one_players))) | { - guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + guild.default_role: PermissionOverwrite(view_channel=False, connect=False)} voice_channel_b_perms = dict(list(map(lambda user: (user, PermissionOverwrite(speak=True, connect=True, view_channel=True)), team_two_players))) | { - guild.default_role: PermissionOverwrite(view_channel=True, connect=False)} + guild.default_role: PermissionOverwrite(view_channel=False, connect=False)} voice_channel_a = await guild.create_voice_channel(name=lobby_a, overwrites=voice_channel_a_perms, category=self.message.channel.category, reason=f"{lobby_a} created") voice_channel_b = await guild.create_voice_channel(name=lobby_b, overwrites=voice_channel_b_perms, category=self.message.channel.category, reason=f"{lobby_b} created") @@ -207,10 +232,10 @@ def generate_match_summary(self) -> str: max_len = max(len(name_1), len(name_2)) return f"""``` -| {' '* (max_len)} | 1v1 | 2v2 | 3v3 | -| {'-'* (max_len)} | {'-'* (3)} | {'-'* (3)} | {'-'* (3)} | -| {name_1:<{max_len}} | {'-' if self.scores['1v1'][0] == None else self.scores['1v1'][0]:^3} | {'-' if self.scores['2v2'][0] == None else self.scores['2v2'][0]:^3} | {'-' if self.scores['3v3'][0] == None else self.scores['3v3'][0]:^3} | -| {name_2:<{max_len}} | {'-' if self.scores['1v1'][1] == None else self.scores['1v1'][1]:^3} | {'-' if self.scores['2v2'][1] == None else self.scores['2v2'][1]:^3} | {'-' if self.scores['3v3'][1] == None else self.scores['3v3'][1]:^3} |```""" +{' '* (max_len)} |1v1|2v2|3v3 #1|3v3 #2|3v3 #3| +{'-'* (max_len)} |{'-'* (3)}|{'-'* (3)}|{'-'* (6)}|{'-'* (6)}|{'-'* (6)}| +{name_1:<{max_len}} |{'-' if self.scores['1v1'][0] == None else self.scores['1v1'][0]:^3}|{'-' if self.scores['2v2'][0] == None else self.scores['2v2'][0]:^3}|{'-' if self.scores['3v3_A'][0] == None else self.scores['3v3_A'][0]:^6}|{'-' if self.scores['3v3_B'][0] == None else self.scores['3v3_B'][0]:^6}|{'-' if self.scores['3v3_C'][0] == None else self.scores['3v3_C'][0]:^6}| +{name_2:<{max_len}} |{'-' if self.scores['1v1'][1] == None else self.scores['1v1'][1]:^3}|{'-' if self.scores['2v2'][1] == None else self.scores['2v2'][1]:^3}|{'-' if self.scores['3v3_A'][1] == None else self.scores['3v3_A'][1]:^6}|{'-' if self.scores['3v3_B'][1] == None else self.scores['3v3_B'][1]:^6}|{'-' if self.scores['3v3_C'][1] == None else self.scores['3v3_C'][1]:^6}|```""" def generate_match_composition(self) -> str: players = DB.rows( @@ -229,16 +254,24 @@ def generate_match_composition(self) -> str: threes_a = [row["UserID"] for row in players if row["Team"] == 1] threes_b = [row["UserID"] for row in players if row["Team"] == 2] - return (f"""### 1v1 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.ONE_V_ONE else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_a]) if ones_a else 'TBD'} **vs** { - ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in ones_b]) if ones_b else 'TBD'} -### 2v2 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.TWO_V_TWO else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_a]) if twos_a else 'TBD'} **vs** { - ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in twos_b]) if twos_b else 'TBD'} -### 3v3 Match {'[NOW PLAYING]' if self.game == SixMansMatchType.THREE_V_THREE else ''} -{', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_a]) if threes_a else 'TBD'} **vs** { - ', '.join([self.bot.get_user(int(user_id)).display_name for user_id in threes_b]) if threes_b else 'TBD'} -""") + match self.game: + case SixMansMatchType.ONE_V_ONE: + return (f"""### Now Playing: 1v1 Match\n{self.generate_members_verse_string(ones_a, ones_b)}""") + case SixMansMatchType.TWO_V_TWO: + return (f"""### Now Playing: 2v2 Match\n{self.generate_members_verse_string(twos_a, twos_b)}""") + case SixMansMatchType.THREE_V_THREE_A: + return (f"""### Now Playing: 3v3 Match #1\n{self.generate_members_verse_string(threes_a, threes_b)}""") + case SixMansMatchType.THREE_V_THREE_B: + return (f"""### Now Playing: 3v3 Match #2\n{self.generate_members_verse_string(threes_a, threes_b)}""") + case SixMansMatchType.THREE_V_THREE_C: + return (f"""### Now Playing: 3v3 Match #3\n{self.generate_members_verse_string(threes_a, threes_b)}""") + + def generate_members_verse_string(self, team_a, team_b): + team_a_string = ', '.join( + [self.bot.get_user(int(user_id)).mention for user_id in team_a]) + team_b_string = ', '.join( + [self.bot.get_user(int(user_id)).mention for user_id in team_b]) + return f"""{team_a_string if team_a else 'TBD'} **vs** {team_b_string if team_b else 'TBD'}""" def generate_embed(self): team_one_players = self.party.get_players(1) @@ -300,10 +333,8 @@ def generate_embed(self): match self.game: case SixMansMatchType.PRE_MATCH: description = f"Now that we have our 1s players sorted, we are ready to get the ball rolling... *pun intended :D*\n\nAmongst yourselves, please nominate a player to host a private match. Whether you create separate 1v1, 2v2, and 3v3 matches or create a single 3v3 match and re-use it for all matches is entirely up to you.\n\nFrom this point onwards, if you would like to see the entire team composition, click the **View team composition** button below.\n\nThe next screen will show you a breakdown of the matches with specific team compositions for each match.\n\nWhen you are ready to move on, click the **Break out** button below and you will be moved automatically into separate channels. May the best team win!" - case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: - match_type = self.get_match_type() - # description = f"{self.generate_match_summary()}\n**You are now playing the {match_type} match.** All match scores will automatically populate where possible. If scores cannot be determined, a '-' will be displayed and you may amend the scores at the very end. Best of luck!\n\n{self.generate_match_composition()}" - description = f"{self.generate_match_summary()}\n**You should now be in game playing!** All match scores will automatically populate where possible. If scores cannot be determined, a '-' will be displayed and you may amend the scores at the very end. Best of luck!\n\n{self.generate_match_composition()}\n\nOnce you have played all your matches, press the **Go to Match Reporting** button below." + case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE_A | SixMansMatchType.THREE_V_THREE_B | SixMansMatchType.THREE_V_THREE_C: + description = f"{self.generate_match_summary()}\n**You should now be in game playing!** All match scores will automatically populate where possible. If scores cannot be determined, a '-' will be displayed and you may amend the scores at the very end. Best of luck!\n\n{self.generate_match_composition()}\n\nOnly once you have played all your matches, press the **Go to Match Reporting** button below." case SixMansState.SCORE_VALIDATION: team_a_scores = list( self.party.reported_scores[self.party.captain_one.id].values()) @@ -360,21 +391,18 @@ def update_options(self): case SixMansMatchType.PRE_MATCH: self.add_button( "Break Out", discord.ButtonStyle.blurple, self.break_out_button_callback) + self.add_button( + "Stay Together", discord.ButtonStyle.grey, self.start_game) self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") - case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE: - # match_label = self.get_match_type() + case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE_A | SixMansMatchType.THREE_V_THREE_B | SixMansMatchType.THREE_V_THREE_C: self.add_button( f"Go to Match Reporting", discord.ButtonStyle.blurple, self.go_to_match_reporting_callback) self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") - # self.add_button( - # f"Surrender {match_label}", discord.ButtonStyle.red, self.surrender_button_callback) case SixMansState.SCORE_VALIDATION: self.add_button( "Review Matches", discord.ButtonStyle.blurple, self.send_report_view) - # self.add_button("View team composition", discord.ButtonStyle.grey, - # self.team_composition_callback, custom_id="team_comp") case SixMansState.POST_MATCH: self.add_button("End Game", discord.ButtonStyle.red, self.cancel_button_callback) @@ -401,11 +429,9 @@ async def choose_button_callback(self, interaction: discord.Interaction): SixMansState.CHOOSE_CAPTAIN_ONE, SixMansState.CHOOSE_1S_PLAYER] else 2 if self.state == SixMansState.CHOOSE_CAPTAIN_TWO else 0, reason=reason) await interaction.response.send_message(view=view, ephemeral=True) - async def break_out_button_callback(self, interaction: discord.Interaction): - await interaction.response.defer() - if self.broken_out: - return - await self.create_break_out_rooms() + async def start_game(self, interaction: discord.Interaction = None): + if interaction != None: + await interaction.response.defer() self.game = SixMansMatchType.ONE_V_ONE DB.execute("INSERT INTO SixManGames () VALUES ()") self.party.game_id = DB.field("SELECT MAX(GameID) FROM SixManGames") @@ -413,20 +439,21 @@ async def break_out_button_callback(self, interaction: discord.Interaction): self.party.game_id, self.party.party_id) await self.update_view() - async def surrender_button_callback(self, interaction: discord.Interaction): - match_type = self.get_match_type() - DB.execute( - f"UPDATE SixManGames SET {match_type}_{'A' if interaction.user.id == self.party.captain_one.id else 'B'} = %s WHERE GameID = %s", -1, self.party.game_id) - if self.party.calculate_winner() == 0: - self.game = SixMansMatchType.THREE_V_THREE if self.game == SixMansMatchType.TWO_V_TWO else SixMansMatchType.TWO_V_TWO - self.state = SixMansState.PLAYING - else: - self.state = SixMansState.POST_MATCH - await self.update_view() - return await interaction.response.send_message(f"You have surrendered the {match_type} match.", ephemeral=True) + async def break_out_button_callback(self, interaction: discord.Interaction): + await interaction.response.defer() + if self.broken_out: + return + await self.create_break_out_rooms() + await self.start_game() async def go_to_match_reporting_callback(self, interaction: discord.Interaction): await interaction.response.defer() + if self.game != SixMansMatchType.THREE_V_THREE_C: + view = ConfirmButtons() + view.message = await interaction.followup.send("Are you sure you have played **all** the necessary matches? You can not go back after match reporting.", view=view, ephemeral=True) + await view.wait() + if not view.confirmed: + return self.state = SixMansState.SCORE_VALIDATION await self.update_view() @@ -451,12 +478,30 @@ async def team_composition_callback(self, interaction: discord.Interaction): await interaction.response.send_message(embed=embed, ephemeral=True) +class ConfirmButtons(View): + def __init__(self, *, timeout=10): + super().__init__(timeout=timeout) + self.confirmed = None + + async def on_timeout(self) -> None: + for item in self.children: + item.disabled = True + await self.message.edit(view=self) + + @discord.ui.button(label="Yes", style=discord.ButtonStyle.green) + async def accept_button(self, interaction: discord.Interaction, button: Button): + self.confirmed = True + self.clear_items() + await interaction.response.edit_message(view=self) + self.stop() + + class TeamDropdown(Select): def __init__(self, teams: list[str], match_type: str, winner: int = -1): self.match_type = match_type options = [discord.SelectOption( label=team, value=idx, default=winner == idx) for idx, team in enumerate(teams)] - super().__init__(placeholder=f"Select the winning team for the {match_type} series", + super().__init__(placeholder=f"Select the winning team for the {match_type.split('_')[0]} series", min_values=1, max_values=1, options=options) async def callback(self, interaction: discord.Interaction): @@ -470,11 +515,17 @@ def __init__(self, ctx: SixMansPrompt) -> None: self.ctx = ctx self.selects = list() - for match in self.ctx.scores.items(): - teams = [f"Team {self.ctx.party.captain_one.display_name}", - f"Team {self.ctx.party.captain_two.display_name}"] - winner = (0 if match[1][0] > match[1][1] else 1) if all( - map(lambda s: s is not None, match[1])) else -1 + teams = [f"Team {self.ctx.party.captain_one.display_name}", + f"Team {self.ctx.party.captain_two.display_name}"] + + # 1v1, 2v2 series + for match in list(self.ctx.scores.items())[:3]: + if match[0].startswith("3v3"): + winner = self.ctx.party.calculate_winner([0 if value is None else value for tup in list( + self.ctx.scores.values())[2:] for value in tup]) - 1 + else: + winner = (0 if match[1][0] > match[1][1] else 1) if all( + map(lambda s: s is not None, match[1])) else -1 select = TeamDropdown(teams, match[0], winner) self.add_item(select) self.selects.append(select) @@ -498,8 +549,32 @@ async def submit_button_callback(self, interaction: discord.Interaction): True, select.options)).value)] = 1 self.ctx.party.reported_scores[interaction.user.id][match_type] = tuple( score) + if match_type.startswith("3v3"): + for m in ["3v3_B", "3v3_C"]: + score = [0, 0] + if select.values: + score[int(select.values[0])] = 1 + else: + score[int(next(filter(lambda o: o.default == + True, select.options)).value)] = 1 + self.ctx.party.reported_scores[interaction.user.id][m] = tuple( + score) + + team_a_scores = list( + self.ctx.party.reported_scores[self.ctx.party.captain_one.id].values()) + team_b_scores = list( + self.ctx.party.reported_scores[self.ctx.party.captain_two.id].values()) + + if all(map(lambda s: s == (None, None), team_a_scores)) and not all(map(lambda s: s == (None, None), team_b_scores)): + waiting_message = f"We have received your report. Thanks! We are currently waiting for {self.ctx.party.captain_one.mention} to review the match results." + elif not all(map(lambda s: s == (None, None), team_a_scores)) and all(map(lambda s: s == (None, None), team_b_scores)): + waiting_message = f"We have received your report. Thanks! We are currently waiting for {self.ctx.party.captain_two.mention} to review the match results." + elif team_a_scores != team_b_scores: + waiting_message = f"**Unfortunately, the reported match outcomes do not match across both team captains. Please resolve this discrepancy.**" + else: + waiting_message = f"Thanks for reporting the match outcomes. Your report matches the opposition's report." - await interaction.response.send_message("Your match report has been submitted successfully. Thanks!", ephemeral=True) + await interaction.response.send_message(waiting_message, ephemeral=True) # Check if both scores are present and compare scores = list(self.ctx.party.reported_scores.values()) if scores[0] == scores[1]: @@ -508,6 +583,10 @@ async def submit_button_callback(self, interaction: discord.Interaction): # Both scores set and equal DB.execute(f"UPDATE SixManGames SET {match_type}_A = %s, {match_type}_B = %s WHERE GameID = %s", scores[0][match_type][0], scores[1][match_type][1], self.ctx.party.game_id) + if match_type.startswith("3v3"): + for m in ["3v3_B", "3v3_C"]: + DB.execute(f"UPDATE SixManGames SET {m}_A = %s, {m}_B = %s WHERE GameID = %s", + scores[0][m][0], scores[1][m][1], self.ctx.party.game_id) self.ctx.state = SixMansState.POST_MATCH await self.ctx.update_view() @@ -664,11 +743,11 @@ async def join_callback(self, interaction: discord.Interaction): else: self.ctx.player_queue.add(interaction.user) await interaction.response.send_message(embed=interaction.client.create_embed("SCUFFBOT SIX MANS", f"You have joined the six mans queue. ({len(self.ctx.player_queue)}/{PARTY_SIZE})", None), ephemeral=True) - await self.update_view() if ((party := self.ctx.player_queue.get_party())): lobby_id, party_id = await self.ctx.create_party(party) await self.update_view() await self.ctx.start(lobby_id, party_id) + await self.update_view() async def leave_callback(self, interaction: discord.Interaction): if not interaction.user in self.ctx.player_queue: @@ -706,6 +785,9 @@ def __init__(self, ctx: SixMansPrompt, message_interaction: discord.Interaction, self.add_item(self.submit_button) async def submit_button_callback(self, interaction: discord.Interaction): + await interaction.response.defer() + if not self.user_dropdown.values: + return await interaction.followup.send("Your selection can not be empty.", ephemeral=True) match self.ctx.state: case SixMansState.CHOOSE_CAPTAIN_ONE: for user_id in self.user_dropdown.values: diff --git a/src/utils/SixMans.py b/src/utils/SixMans.py index a883d07..f89cd7b 100644 --- a/src/utils/SixMans.py +++ b/src/utils/SixMans.py @@ -26,7 +26,9 @@ class SixMansMatchType(Enum): PRE_MATCH = 0 ONE_V_ONE = 1 TWO_V_TWO = 2 - THREE_V_THREE = 3 + THREE_V_THREE_A = 3 + THREE_V_THREE_B = 4 + THREE_V_THREE_C = 5 class SixMansParty(): @@ -41,8 +43,11 @@ def __init__(self, bot: discord.Client, party_id: int) -> None: self.captain_two: Union[None, discord.Member] = None self.generate_captains() - self.reported_scores = {self.captain_one.id: {"1v1": (None, None), "2v2": (None, None), "3v3": ( - None, None)}, self.captain_two.id: {"1v1": (None, None), "2v2": (None, None), "3v3": (None, None)}} + self.reported_scores = { + self.captain_one.id: {"1v1": (None, None), "2v2": (None, None), "3v3_A": (None, None), "3v3_B": (None, None), "3v3_C": (None, None)}, + self.captain_two.id: {"1v1": (None, None), "2v2": (None, None), "3v3_A": ( + None, None), "3v3_B": (None, None), "3v3_C": (None, None)} + } async def get_details(self): return DB.row("SELECT * FROM SixManLobby WHERE LobbyID = %s", self.lobby_id) @@ -62,11 +67,16 @@ def generate_captains(self): DB.execute( "UPDATE SixManUsers SET Type = 2, Team = 2 WHERE UserID = %s", self.captain_two.id) - def calculate_winner(self) -> Literal[0, 1, 2]: + def calculate_winner(self, data=None) -> Literal[0, 1, 2]: if self.game_id == None: return 0 - data = [0 if x is None else x for x in DB.row( - "SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B, 3v3_A, 3v3_B FROM SixManGames WHERE GameID = %s", self.game_id).values()] + if data == None: + threes_winner = self.calculate_winner(list(DB.row( + "SELECT 3v3_A_A, 3v3_A_B, 3v3_B_A, 3v3_B_B, 3v3_C_A, 3v3_C_B FROM SixManGames WHERE GameID = %s", self.game_id).values())) + threes_score = [0, 0] + threes_score[threes_winner - 1] = 1 + data = [0 if x is None else x for x in list(DB.row( + "SELECT 1v1_A, 1v1_B, 2v2_A, 2v2_B FROM SixManGames WHERE GameID = %s", self.game_id).values()) + threes_score] team_a_wins = sum(data[i] > data[i + 1] for i in range(0, len(data), 2)) From 6b7f8352fcf877ab987da5df6e383359869542d4 Mon Sep 17 00:00:00 2001 From: MasterOfCubesAU Date: Thu, 27 Feb 2025 09:04:47 +1100 Subject: [PATCH 23/26] Changed match creation to be of code/password type --- db/data/init.sql | 2 ++ src/lib/cogs/SixMans.py | 32 ++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/db/data/init.sql b/db/data/init.sql index 89b1f44..dcc761d 100644 --- a/db/data/init.sql +++ b/db/data/init.sql @@ -22,6 +22,8 @@ CREATE TABLE `SixManGames` ( `GameID` INTEGER NOT NULL AUTO_INCREMENT, `Timestamp` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `MatchCode` TEXT NOT NULL, + `MatchPassword` TEXT NOT NULL, `1v1_A` INTEGER DEFAULT NULL, `1v1_B` INTEGER DEFAULT NULL, `2v2_A` INTEGER DEFAULT NULL, diff --git a/src/lib/cogs/SixMans.py b/src/lib/cogs/SixMans.py index beac03c..b77e7e0 100644 --- a/src/lib/cogs/SixMans.py +++ b/src/lib/cogs/SixMans.py @@ -8,6 +8,7 @@ from typing import Any, Callable, Union import discord import logging +import uuid from src.utils.SixMans import LOBBY_TIMEOUT, PARTY_SIZE, SixMansQueue, SixMansState, SixMansMatchType, SixMansParty @@ -124,7 +125,7 @@ async def on_voice_state_update(self, member, before, after): await self.update_view() async def interaction_check(self, interaction: discord.Interaction): - if "custom_id" in interaction.data and interaction.data["custom_id"] == "team_comp": + if "custom_id" in interaction.data and interaction.data["custom_id"] in ["team_comp", "match_info"]: return True match self.state: case SixMansState.CHOOSE_CAPTAIN_ONE: @@ -332,7 +333,15 @@ def generate_embed(self): case SixMansState.PLAYING: match self.game: case SixMansMatchType.PRE_MATCH: - description = f"Now that we have our 1s players sorted, we are ready to get the ball rolling... *pun intended :D*\n\nAmongst yourselves, please nominate a player to host a private match. Whether you create separate 1v1, 2v2, and 3v3 matches or create a single 3v3 match and re-use it for all matches is entirely up to you.\n\nFrom this point onwards, if you would like to see the entire team composition, click the **View team composition** button below.\n\nThe next screen will show you a breakdown of the matches with specific team compositions for each match.\n\nWhen you are ready to move on, click the **Break out** button below and you will be moved automatically into separate channels. May the best team win!" + match_code = f"SCUFF-{uuid.uuid4().hex.upper()[:3]}" + match_password = uuid.uuid4().hex.upper()[:6] + DB.execute( + "INSERT INTO SixManGames (MatchCode, MatchPassword) VALUES (%s, %s)", match_code, match_password) + self.party.game_id = DB.field( + "SELECT MAX(GameID) FROM SixManGames") + DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", + self.party.game_id, self.party.party_id) + description = f"Now that we have our 1s players sorted, we are ready to get the ball rolling... *pun intended :D*\n\nNominate a player to create a 3v3 private match with a lobby code of **{match_code}** and a password of **{match_password}**.\n\nOnce everyone is in game, either click the **Break Out** or **Stay Together** button below to split off into separate calls or stay in the current voice channel respectively. May the best team win!" case SixMansMatchType.ONE_V_ONE | SixMansMatchType.TWO_V_TWO | SixMansMatchType.THREE_V_THREE_A | SixMansMatchType.THREE_V_THREE_B | SixMansMatchType.THREE_V_THREE_C: description = f"{self.generate_match_summary()}\n**You should now be in game playing!** All match scores will automatically populate where possible. If scores cannot be determined, a '-' will be displayed and you may amend the scores at the very end. Best of luck!\n\n{self.generate_match_composition()}\n\nOnly once you have played all your matches, press the **Go to Match Reporting** button below." case SixMansState.SCORE_VALIDATION: @@ -400,6 +409,8 @@ def update_options(self): f"Go to Match Reporting", discord.ButtonStyle.blurple, self.go_to_match_reporting_callback) self.add_button("View Team Composition", discord.ButtonStyle.grey, self.team_composition_callback, custom_id="team_comp") + self.add_button("View Match Info", discord.ButtonStyle.grey, + self.match_info_callback, custom_id="match_info") case SixMansState.SCORE_VALIDATION: self.add_button( "Review Matches", discord.ButtonStyle.blurple, self.send_report_view) @@ -433,10 +444,6 @@ async def start_game(self, interaction: discord.Interaction = None): if interaction != None: await interaction.response.defer() self.game = SixMansMatchType.ONE_V_ONE - DB.execute("INSERT INTO SixManGames () VALUES ()") - self.party.game_id = DB.field("SELECT MAX(GameID) FROM SixManGames") - DB.execute("UPDATE SixManParty SET GameID = %s WHERE PartyID = %s", - self.party.game_id, self.party.party_id) await self.update_view() async def break_out_button_callback(self, interaction: discord.Interaction): @@ -477,6 +484,19 @@ async def team_composition_callback(self, interaction: discord.Interaction): text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") await interaction.response.send_message(embed=embed, ephemeral=True) + async def match_info_callback(self, interaction: discord.Interaction): + data = DB.row("SELECT MatchCode, MatchPassword FROM SixManGames WHERE GameID = %s", + self.party.game_id) + embed: discord.Embed = self.bot.create_embed( + f"ScuffBot Six Mans #{self.party.lobby_id}", "", None) + embed.add_field( + name=f"Match Code", value=data["MatchCode"]) + embed.add_field( + name=f"Match Password", value=data["MatchPassword"]) + embed.set_footer( + text=f"Party {'N/A' if not self.party.party_id else self.party.party_id} | Game {'N/A' if not self.party.game_id else self.party.game_id}") + await interaction.response.send_message(embed=embed, ephemeral=True) + class ConfirmButtons(View): def __init__(self, *, timeout=10): From f1420e517d6a087c444965120541607d2b07854d Mon Sep 17 00:00:00 2001 From: Sam Zheng Date: Wed, 31 Dec 2025 15:24:29 +1300 Subject: [PATCH 24/26] feat: use longhorn for pvcs + simplify secrets in Helm chart --- .github/workflows/deploy.yaml | 97 ++--------------------- .github/workflows/helmfile-diff.yaml | 15 ++++ dagger.json | 9 +++ helmfile.yaml.gotmpl | 37 +++++++++ infra/{values.yaml => values.yaml.gotmpl} | 27 ++++--- 5 files changed, 85 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/helmfile-diff.yaml create mode 100644 dagger.json create mode 100644 helmfile.yaml.gotmpl rename infra/{values.yaml => values.yaml.gotmpl} (63%) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index a787a0d..6b679fd 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,12 +1,9 @@ -name: Build, push, and deploy +name: Deploy on: pull_request: - branches: - - main push: - branches: - - main + branches: [main] workflow_dispatch: inputs: env: @@ -19,88 +16,10 @@ on: - prod jobs: - build: - name: Build and push to Docker Hub - runs-on: arc-runner-set - outputs: - commit-sha: ${{ steps.extractor.outputs.short_sha }} - environment: ${{ steps.set-environment.outputs.env }} - repo-name: ${{ steps.extractor.outputs.repo_name }} - steps: - - name: Determine Environment - id: set-environment - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - echo "env=${{ github.event.inputs.env }}" >> $GITHUB_OUTPUT - elif [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "env=staging" >> $GITHUB_OUTPUT - elif [[ "${{ github.event_name }}" == "push" ]]; then - echo "env=prod" >> $GITHUB_OUTPUT - else - echo "Failed to determine environment" - exit 1 - fi - env: - GITHUB_EVENT_NAME: ${{ github.event_name }} - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.ORG_DOCKERHUB_USERNAME }} - password: ${{ secrets.ORG_DOCKERHUB_TOKEN }} - - - name: Extract repository name and commit SHA - id: extractor - run: | - echo "${{ github.repository }}" | sed -E "s|^.*/(.*)$|repo_name=\1|" | tr "[:upper:]" "[:lower:]" >> $GITHUB_OUTPUT - echo "${{ github.sha }}" | sed -E "s|^(.{7}).*$|short_sha=\1|" >> $GITHUB_OUTPUT - - - name: Build and push to ${{ steps.set-environment.outputs.env }} - uses: docker/build-push-action@v6 - with: - push: true - tags: | - ${{ secrets.ORG_DOCKERHUB_USERNAME }}/${{ secrets.ORG_DOCKERHUB_REPO }}:${{ steps.extractor.outputs.repo_name }}-${{ steps.set-environment.outputs.env }} - deploy: - name: Deploy to Kubernetes - needs: build - runs-on: ubuntu-latest - steps: - - name: Checkout infrastructure config - uses: actions/checkout@v4 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.ORG_DOCKERHUB_USERNAME }} - password: ${{ secrets.ORG_DOCKERHUB_TOKEN }} - - - name: Set kube context - uses: azure/k8s-set-context@v4 - with: - method: service-account - k8s-url: https://kube.masterofcubesau.com:6443 - k8s-secret: ${{ secrets.ORG_K3S_AUTH_TOKEN }} - - - name: Add internal chartmuseum - run: | - helm repo add chartmuseum https://chartmuseum.masterofcubesau.com \ - --username ${{ secrets.ORG_CHARTMUSEUM_USER }} \ - --password ${{ secrets.ORG_CHARTMUSEUM_PASS }} - - - name: Deploy application to ${{ needs.build.outputs.environment }} - env: - ENVIRONMENT: ${{ needs.build.outputs.environment }} - REPO_NAME: ${{ needs.build.outputs.repo-name }} - COMMIT_SHA: ${{ needs.build.outputs.commit-sha }} - DB_PASSWORD: ${{ secrets.DB_PASSWORD }} - DB_ROOT_PASSWORD: ${{ secrets.DB_ROOT_PASSWORD }} - DOCKERHUB_USERNAME: ${{ secrets.ORG_DOCKERHUB_USERNAME }} - DOCKERHUB_REPO: ${{ secrets.ORG_DOCKERHUB_REPO }} - BOT_TOKEN: ${{ needs.build.outputs.environment == 'prod' && secrets.BOT_TOKEN_PROD || secrets.BOT_TOKEN_STAGING }} - run: | - cat infra/values.yaml | envsubst | \ - helm upgrade --install "$REPO_NAME" chartmuseum/generic-app --version 0.1.1 \ - -f - --set-file scuffbotConfig=config.yaml --set-file sqlInitFile=db/data/init.sql \ - --namespace="$REPO_NAME-$ENVIRONMENT" --create-namespace --atomic --timeout=1m --cleanup-on-fail + uses: mocbotau/infra-workflows/.github/workflows/generic-deploy.yaml@v0.4.0 + with: + event-name: ${{ github.event_name }} + environment: ${{ github.event.inputs.env }} + repo-name: "scuffbot" + secrets: inherit diff --git a/.github/workflows/helmfile-diff.yaml b/.github/workflows/helmfile-diff.yaml new file mode 100644 index 0000000..a504209 --- /dev/null +++ b/.github/workflows/helmfile-diff.yaml @@ -0,0 +1,15 @@ +name: Helmfile Diff + +on: + pull_request: + +jobs: + helmfile-diff: + uses: mocbotau/infra-workflows/.github/workflows/generic-helmfile-diff.yaml@v0.4.0 + with: + run-staging: true + secrets: inherit + +permissions: + contents: read + pull-requests: write diff --git a/dagger.json b/dagger.json new file mode 100644 index 0000000..379553d --- /dev/null +++ b/dagger.json @@ -0,0 +1,9 @@ +{ + "name": "ScuffBot", + "engineVersion": "v0.19.8", + "blueprint": { + "name": "generic-deploy", + "source": "github.com/mocbotau/infra-dagger-modules/blueprints/generic-deploy@v0.2.0", + "pin": "17d58b481e79dd734fdbd762ad77a35a2a5bbe4f" + } +} diff --git a/helmfile.yaml.gotmpl b/helmfile.yaml.gotmpl new file mode 100644 index 0000000..e5cbd4d --- /dev/null +++ b/helmfile.yaml.gotmpl @@ -0,0 +1,37 @@ +environments: + staging: + values: + - environment: staging + prod: + values: + - environment: prod + +--- +repositories: + - name: chartmuseum + url: https://chartmuseum.masterofcubesau.com + username: {{ requiredEnv "CHARTMUSEUM_USER" }} + password: {{ requiredEnv "CHARTMUSEUM_PASS" }} + +helmDefaults: + atomic: true + cleanupOnFail: true + createNamespace: true + suppressOutputLineRegex: + - "version" + timeout: 60 + wait: true + waitForJobs: true + +releases: + - name: scuffbot + chart: chartmuseum/generic-app + version: 0.4.0 + namespace: scuffbot-{{ .Environment.Values.environment }} + values: + - infra/values.yaml.gotmpl + set: + - name: scuffbotConfig + file: config.yaml + - name: sqlInitFile + file: db/data/init.sql diff --git a/infra/values.yaml b/infra/values.yaml.gotmpl similarity index 63% rename from infra/values.yaml rename to infra/values.yaml.gotmpl index 258996e..32c70d9 100644 --- a/infra/values.yaml +++ b/infra/values.yaml.gotmpl @@ -1,10 +1,10 @@ -# TODO: Replace schema link below to permanent location # yaml-language-server: $schema=../../infra-helm-charts/charts/generic-app/values.schema.json -environment: "${ENVIRONMENT}" -version: "${COMMIT_SHA}" +environment: {{ .Environment.Values.environment | quote }} +version: {{ requiredEnv "COMMIT_SHA" | quote }} + deployments: - name: "scuffbot" - image: "${DOCKERHUB_USERNAME}/${DOCKERHUB_REPO}:${REPO_NAME}-${ENVIRONMENT}" + image: "mocbotau/cloud:scuffbot-{{ .Environment.Values.environment }}" config: fileVariable: "scuffbotConfig" environment: @@ -14,9 +14,11 @@ deployments: DB_HOST: "service-scuffbot-db" DB_PASSWORD_FILE: "/secrets/db-password" DB_USER: "SCUFFBOT" - secrets: - bot-token: "${BOT_TOKEN}" - db-password: "${DB_PASSWORD}" + secretProviderClass: + projectId: "e1420e9b-7c7d-4f54-b916-74d17f594a83" + secrets: + - secretKey: "BOT_TOKEN" + - secretKey: "DB_PASSWORD" statefulSets: - name: "scuffbot-db" @@ -34,14 +36,17 @@ statefulSets: readinessProbe: tcpSocket: port: 3306 - secrets: - db-password: "${DB_PASSWORD}" - db-root-password: "${DB_ROOT_PASSWORD}" + secretProviderClass: + projectId: "e1420e9b-7c7d-4f54-b916-74d17f594a83" + secrets: + - secretKey: "DB_PASSWORD" + - secretKey: "DB_ROOT_PASSWORD" service: type: "ClusterIP" port: 3306 targetPort: 3306 volumes: - - name: "scuffbot-db" + - name: "scuffbot-db-longhorn" + storageClass: "longhorn" mountPath: "/var/lib/mysql" size: "1Gi" From 8559030e5c8d48b2142d28ae97de7eae04950dc7 Mon Sep 17 00:00:00 2001 From: Sam Zheng Date: Fri, 9 Jan 2026 20:27:12 +1300 Subject: [PATCH 25/26] chore: update Dagger to v0.19.9 --- dagger.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dagger.json b/dagger.json index 379553d..c63b150 100644 --- a/dagger.json +++ b/dagger.json @@ -1,9 +1,9 @@ { "name": "ScuffBot", - "engineVersion": "v0.19.8", + "engineVersion": "v0.19.9", "blueprint": { "name": "generic-deploy", - "source": "github.com/mocbotau/infra-dagger-modules/blueprints/generic-deploy@v0.2.0", - "pin": "17d58b481e79dd734fdbd762ad77a35a2a5bbe4f" + "source": "github.com/mocbotau/infra-dagger-modules/blueprints/generic-deploy@v0.2.1", + "pin": "d3b5d9928ca1aa7ab0199362d2d0f0e7cd6e792a" } } From 245701ccd0f0a0f07db2126c705e5fa9780a9fbb Mon Sep 17 00:00:00 2001 From: Sam Zheng Date: Mon, 30 Mar 2026 21:12:18 +1100 Subject: [PATCH 26/26] chore: bump dagger to 20.3 --- dagger.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dagger.json b/dagger.json index c63b150..1671df8 100644 --- a/dagger.json +++ b/dagger.json @@ -1,9 +1,9 @@ { "name": "ScuffBot", - "engineVersion": "v0.19.9", + "engineVersion": "v0.20.3", "blueprint": { "name": "generic-deploy", - "source": "github.com/mocbotau/infra-dagger-modules/blueprints/generic-deploy@v0.2.1", - "pin": "d3b5d9928ca1aa7ab0199362d2d0f0e7cd6e792a" + "source": "github.com/mocbotau/infra-dagger-modules/blueprints/generic-deploy@v0.2.3", + "pin": "baa540a83e74639fb6ff0b1ef1a51a35dd688e85" } }