From bfb33f97a30fa399a2536918fd928fad6d6474ef Mon Sep 17 00:00:00 2001 From: Evanroby <107794516+Evanroby@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:59:41 +0200 Subject: [PATCH 1/2] multiple improvements to honeycomb --- honeycombs/honeycombs.py | 274 +++++++++++++++++++++++++++------------ honeycombs/info.json | 2 +- honeycombs/view.py | 14 +- 3 files changed, 200 insertions(+), 90 deletions(-) diff --git a/honeycombs/honeycombs.py b/honeycombs/honeycombs.py index 954dff7b..dd72c29f 100644 --- a/honeycombs/honeycombs.py +++ b/honeycombs/honeycombs.py @@ -25,19 +25,17 @@ import asyncio import logging import random -from datetime import datetime, timedelta -from io import StringIO -from typing import Any, Final, Optional +from datetime import datetime, timedelta, timezone +from io import BytesIO +from typing import Any, Final, Optional, cast import aiohttp import discord -from redbot.core import Config, bank, commands, errors +from redbot.core import Config, bank, commands from redbot.core.bot import Red from redbot.core.utils.chat_formatting import ( - header, humanize_list, humanize_number, - hyperlink, ) from redbot.core.utils.views import ConfirmView, SimpleMenu @@ -46,20 +44,34 @@ log = logging.getLogger("red.maxcogs.honeycombs") +MAGIC_BYTES: dict[bytes, str] = { + b"\xff\xd8\xff": "JPEG/JPG", + b"\x89PNG": "PNG", + b"GIF8": "GIF", + b"RIFF": "WEBP", +} + +DEFAULT_SHAPE_ODDS: dict[str, int] = { + "circle": 20, + "triangle": 20, + "star": 20, + "umbrella": 8, +} + class GameState: def __init__(self, guild: discord.Guild): self.guild = guild self.active = False - self.players = {} - self.start_time = None - self.end_time = None + self.players: dict[int, dict[str, Any]] = {} + self.start_time: Optional[datetime] = None + self.end_time: Optional[datetime] = None class HoneyCombs(commands.Cog): """Play a game similar to Sugar Honeycombs, inspired by the Netflix series Squid Game.""" - __version__: Final[str] = "2.1.0" + __version__: Final[str] = "2.2.0" __author__: Final[str] = "MAX" __docs__: Final[str] = "https://cogs.maxapp.tv/" @@ -69,12 +81,14 @@ def __init__(self, bot: Red): self.cache = {"global": {}, "guilds": {}} self.game_states = {} self.locks = {} + self._game_tasks: dict[int, asyncio.Task] = {} self.session = aiohttp.ClientSession() default_guild = { "players": {}, "game_active": False, "default_start_image": "https://i.maxapp.tv/4c76241E.png", "shapes": ["circle⭕️", "triangle△", "star⭐️", "umbrella☂️"], + "shape_odds": DEFAULT_SHAPE_ODDS, "mod_only_command": False, "minimum_players": 2, "default_minutes": 10, @@ -97,6 +111,7 @@ async def red_delete_data_for_user(self, **kwargs: Any) -> None: return async def initialize_cache(self): + await self.bot.wait_until_ready() self.cache["global"] = await self.config.all() for guild in self.bot.guilds: self.cache["guilds"][guild.id] = await self.config.guild(guild).all() @@ -126,9 +141,15 @@ def get_lock(self, guild: discord.Guild) -> asyncio.Lock: async def cog_unload(self): await self.session.close() - for guild in self.bot.guilds: - await self.config.guild(guild).players.clear() - await self.config.guild(guild).game_active.set(False) + for guild_id, task in list(self._game_tasks.items()): + task.cancel() + game_state = self.game_states.get(guild_id) + if game_state and game_state.active: + guild = self.bot.get_guild(guild_id) + if guild: + await self.config.guild(guild).players.clear() + await self.config.guild(guild).game_active.set(False) + self._game_tasks.clear() async def run_game(self, ctx: commands.Context): game_state = self.get_game_state(ctx.guild) @@ -140,13 +161,17 @@ async def run_game(self, ctx: commands.Context): return default_minutes = guild_config["default_minutes"] - game_state.end_time = datetime.now() + timedelta(minutes=default_minutes) + game_state.end_time = datetime.now(timezone.utc) + timedelta(minutes=default_minutes) end_timestamp = int(game_state.end_time.timestamp()) embed = discord.Embed( title="Sugar Honeycombs Challenge", color=await ctx.embed_color(), - description=f"Let the game begin!\nThe bot will finish to decide whether you Pass or Eliminated.\nGood luck!", + description=( + f"Let the game begin!\n" + f"The bot will finish to decide whether you Pass or are Eliminated.\n" + f"Good luck!" + ), ) embed.add_field(name="Players:", value=len(game_state.players)) embed.set_footer(text="I would like to expend a heartfelt welcome to you all.") @@ -157,6 +182,8 @@ async def run_game(self, ctx: commands.Context): winning_price = self.cache["global"]["winning_price"] losing_price = self.cache["global"]["losing_price"] currency_name = await bank.get_currency_name(ctx.guild) + guild_config = await self.get_guild_config(ctx.guild) + shape_odds = guild_config.get("shape_odds", dict(DEFAULT_SHAPE_ODDS)) passed_players, failed_players = [], [] error_messages = [] @@ -165,29 +192,66 @@ async def run_game(self, ctx: commands.Context): user = ctx.guild.get_member(data["user_id"]) if user: shape = data["shape"] - chance = 0.08 if shape == "umbrella☂️" else 0.2 + shape_key = next((k for k in shape_odds if k in shape.lower()), None) + chance = shape_odds.get(shape_key, 20) / 100 if shape_key else 0.20 + player_label = ( + f"Player {num} — {user.display_name} (ID: {user.id}) | Shape: {shape}" + ) + if random.random() < chance: - success, message = await safe_deposit(user, winning_price, currency_name) - if success: - passed_players.append( - f"Player {num}, Shape: {shape} - (User ID: {data['user_id']})" - ) + if winning_price > 0: + success, message = await safe_deposit(user, winning_price, currency_name) + if not success: + error_messages.append(message) + passed_players.append(f"{player_label} — Deposit Failed") + else: + passed_players.append(player_label) else: - error_messages.append(message) - passed_players.append( - f"Player {num}, Shape: {shape} - (User ID: {data['user_id']}) - Deposit Failed" + passed_players.append(player_label) + try: + dm_embed = discord.Embed( + title="🎉 You Passed!", + description=( + "You successfully carved your shape in the Sugar Honeycombs Challenge!" + + ( + f"\n\nYou received **{humanize_number(winning_price)} {currency_name}**." + if winning_price > 0 + else "" + ) + ), + color=discord.Color.green(), ) + dm_embed.set_footer(text=f"Game was held in {ctx.guild.name}") + await user.send(embed=dm_embed) + except discord.HTTPException: + pass else: - success, message = await safe_withdraw(user, losing_price, currency_name) - if success: - failed_players.append( - f"Player {num}, Shape: {shape} - (User ID: {data['user_id']})" - ) + if losing_price > 0: + success, message = await safe_withdraw(user, losing_price, currency_name) + if not success: + error_messages.append(message) + failed_players.append(f"{player_label} — Withdrawal Failed") + else: + failed_players.append(player_label) else: - error_messages.append(message) - failed_players.append( - f"Player {num}, Shape: {shape} - (User ID: {data['user_id']}) - Withdrawal Failed" + failed_players.append(player_label) + try: + dm_embed = discord.Embed( + title="💀 You were Eliminated.", + description=( + "Your shape broke during the Sugar Honeycombs Challenge." + + ( + f"\n\nYou lost **{humanize_number(losing_price)} {currency_name}**." + if losing_price > 0 + else "" + ) + ), + color=discord.Color.red(), ) + dm_embed.set_footer(text=f"Game was held in {ctx.guild.name}") + await user.send(embed=dm_embed) + except discord.HTTPException: + pass passed_content = ( "Passed Players:\n" + "\n".join(passed_players) @@ -222,13 +286,12 @@ async def run_game(self, ctx: commands.Context): value=f"{humanize_number(losing_price)} {currency_name}", ) embed.set_footer(text="Thank you for playing!") - file = discord.File(StringIO(full_content), filename="honeycombs_results.txt") + file = discord.File(BytesIO(full_content.encode()), filename="honeycombs_results.txt") await ctx.send(embed=embed, file=file) game_state.players.clear() game_state.active = False - await self.update_guild_config(ctx.guild, "players", {}) - await self.update_guild_config(ctx.guild, "game_active", False) + self._game_tasks.pop(ctx.guild.id, None) @commands.guild_only() @commands.hybrid_command() @@ -252,7 +315,7 @@ async def honeycombs(self, ctx: commands.Context): if ( guild_config["mod_only_command"] - and not ctx.author.guild_permissions.manage_messages + and not cast(discord.Member, ctx.author).guild_permissions.manage_messages ): return await ctx.send( "This command is only available to moderators.", @@ -260,24 +323,25 @@ async def honeycombs(self, ctx: commands.Context): ) game_state.active = True - game_state.start_time = datetime.now() + timedelta(minutes=2) - end_time = int(game_state.start_time.timestamp()) - view = HoneycombView(self, ctx.guild) - - winning_price = self.cache["global"]["winning_price"] - losing_price = self.cache["global"]["losing_price"] - total_price = ( - humanize_number(winning_price + losing_price) - if (winning_price + losing_price) != 0 - else "Free" - ) - currency_name = await bank.get_currency_name(ctx.guild) - minimum_players = guild_config["minimum_players"] + game_state.start_time = datetime.now(timezone.utc) + timedelta(minutes=2) + end_time = int(game_state.start_time.timestamp()) + view = HoneycombView(self, ctx.guild) + + winning_price = self.cache["global"]["winning_price"] + losing_price = self.cache["global"]["losing_price"] + total_price = ( + humanize_number(winning_price + losing_price) + if (winning_price + losing_price) != 0 + else "Free" + ) + currency_name = await bank.get_currency_name(ctx.guild) + minimum_players = guild_config["minimum_players"] - await view.setup(total_price, currency_name, minimum_players, end_time) - message = await ctx.send(view=view) - view.message = message - await self.wait_for_players(ctx, view) + await view.setup(total_price, currency_name, minimum_players, end_time) + message = await ctx.send(view=view) + view.message = message + task = self.bot.loop.create_task(self.wait_for_players(ctx, view)) + self._game_tasks[ctx.guild.id] = task async def wait_for_players(self, ctx: commands.Context, view: HoneycombView): guild_config = await self.get_guild_config(ctx.guild) @@ -291,8 +355,10 @@ async def wait_for_players(self, ctx: commands.Context, view: HoneycombView): ) game_state.active = False game_state.players.clear() + self._game_tasks.pop(ctx.guild.id, None) return + view.stop() await view.on_timeout() await self.run_game(ctx) @@ -305,16 +371,16 @@ async def honeycombset(self, ctx: commands.Context): @commands.cooldown(1, 60, commands.BucketType.guild) async def checklist(self, ctx: commands.Context): """ - Check the list of players in current game. + Check the list of players in the current game. - This command will show the list of players who have joined the game along with their player numbers. + Shows all players who have joined along with their player numbers. """ game_state = self.get_game_state(ctx.guild) if not game_state.players: return await ctx.send("No ongoing game found.") - player_list = [f"Player {player_number}" for player_number in game_state.players.keys()] - pages = [humanize_number(player_list[i : i + 10]) for i in range(0, len(player_list), 10)] + player_list = [f"Player {number}" for number in game_state.players.keys()] + pages = ["\n".join(player_list[i : i + 10]) for i in range(0, len(player_list), 10)] await SimpleMenu( pages, disable_after_timeout=True, @@ -334,9 +400,12 @@ async def reset(self, ctx: commands.Context): return await ctx.send("Game settings are already reset.") view = ConfirmView(ctx.author, disable_buttons=True) - message = await ctx.send("Are you sure you want to reset the game settings?", view=view) + await ctx.send("Are you sure you want to reset the game settings?", view=view) await view.wait() if view.result: + task = self._game_tasks.pop(ctx.guild.id, None) + if task: + task.cancel() game_state.players.clear() game_state.active = False default_guild = { @@ -344,8 +413,9 @@ async def reset(self, ctx: commands.Context): "game_active": False, "default_start_image": "https://i.maxapp.tv/4c76241E.png", "shapes": ["circle⭕️", "triangle△", "star⭐️", "umbrella☂️"], + "shape_odds": DEFAULT_SHAPE_ODDS, "mod_only_command": False, - "minimum_players": 5, + "minimum_players": 2, "default_minutes": 10, } self.cache["guilds"][ctx.guild.id] = default_guild @@ -354,8 +424,8 @@ async def reset(self, ctx: commands.Context): else: await ctx.send("Game settings were not reset.") - @commands.admin() @honeycombset.group(name="setimage", aliases=["setimg"], invoke_without_command=True) + @commands.admin() async def setimage(self, ctx: commands.Context, *, image_url: Optional[str] = None): """ Set the start image for the game. @@ -370,28 +440,26 @@ async def setimage(self, ctx: commands.Context, *, image_url: Optional[str] = No elif image_url is None: return await ctx.send("You must provide a URL or attach an image.") - async with aiohttp.ClientSession() as session: - try: - async with session.get(image_url) as r: - if r.status != 200: - return await ctx.send("Failed to set the start image. URL is invalid.") - data = await r.read() - except aiohttp.ClientError as e: - return await ctx.send("Failed to set the start image. Client error.") - except asyncio.TimeoutError as e: - return await ctx.send("Failed to set the start image. Timeout error.") - - image_formats = ["JPG", "JPEG", "PNG", "GIF", "WEBP"] - if not data or not any(data.startswith(bytes(f"{sig}", "utf-8")) for sig in image_formats): + try: + async with self.session.get(image_url, timeout=aiohttp.ClientTimeout(total=10)) as r: + if r.status != 200: + return await ctx.send("Failed to set the start image. URL is invalid.") + data = await r.read() + except aiohttp.ClientError: + return await ctx.send("Failed to set the start image. Client error.") + except asyncio.TimeoutError: + return await ctx.send("Failed to set the start image. Timeout error.") + + if not data or not any(data.startswith(sig) for sig in MAGIC_BYTES): return await ctx.send( - f"Failed to set the start image. Only {humanize_list(image_formats)} format is supported." + f"Failed to set the start image. Only {humanize_list(list(MAGIC_BYTES.values()))} format is supported." ) await self.update_guild_config(ctx.guild, "default_start_image", image_url) await ctx.send("The start image has been set.") - @commands.admin() @setimage.command(name="clear", aliases=["reset"]) + @commands.admin() async def setimage_clear(self, ctx: commands.Context): """Reset the start image to default.""" default_image = "https://i.maxapp.tv/4c76241E.png" @@ -419,8 +487,8 @@ async def endtime(self, ctx: commands.Context, default_minutes: commands.Range[i """ Change the default minutes for when the game should end. - The default minutes is 10. - The maximum number of minutes is 720 (12 hours). + The default is 10 minutes. + The maximum is 720 minutes (12 hours). """ await self.update_guild_config(ctx.guild, "default_minutes", default_minutes) await ctx.send(f"The default minutes has been set to {default_minutes} minutes.") @@ -433,11 +501,41 @@ async def minimum_players( """ Set the minimum number of players needed to start a game. - The default minimum number of players is 5. + The default minimum is 2 players. """ await self.update_guild_config(ctx.guild, "minimum_players", minimum_players) await ctx.send(f"The minimum number of players has been set to {minimum_players}.") + @commands.admin() + @honeycombset.command(name="shapeodds") + async def shape_odds_cmd( + self, + ctx: commands.Context, + shape: str, + percentage: commands.Range[int, 1, 99], + ): + """ + Set the pass-chance percentage for a specific shape. + + Shape must be one of: circle, triangle, star, umbrella. + Percentage is 1–99 (e.g. 20 means a 20% chance of passing). + + Default values: + - circle: 20% + - triangle: 20% + - star: 20% + - umbrella: 8% + """ + shape = shape.lower() + valid_shapes = list(DEFAULT_SHAPE_ODDS.keys()) + if shape not in valid_shapes: + return await ctx.send(f"Invalid shape. Must be one of: {humanize_list(valid_shapes)}.") + guild_config = await self.get_guild_config(ctx.guild) + odds = dict(guild_config.get("shape_odds", DEFAULT_SHAPE_ODDS)) + odds[shape] = percentage + await self.update_guild_config(ctx.guild, "shape_odds", odds) + await ctx.send(f"Pass chance for **{shape}** has been set to **{percentage}%**.") + @commands.is_owner() @honeycombset.command(name="winningprice") async def winning_price( @@ -503,18 +601,24 @@ async def settings(self, ctx: commands.Context): ) embed.add_field( name="Minimum Players", - value=guild_config.get("minimum_players", 5), + value=guild_config.get("minimum_players", 2), + inline=False, + ) + embed.add_field( + name="Default Game Duration", + value=f"{guild_config.get('default_minutes', 10)} minutes", inline=False, ) + shape_odds = guild_config.get("shape_odds", DEFAULT_SHAPE_ODDS) + odds_display = "\n".join(f"{shape}: {pct}%" for shape, pct in shape_odds.items()) embed.add_field( - name="Default ongoing game minutes", - value=guild_config.get("default_minutes", 10), + name="Shape Pass Odds", + value=odds_display, inline=False, ) start_image_url = guild_config.get("default_start_image", None) - if start_image_url: - start_image_value = f"[View Start Image]({start_image_url})" - else: - start_image_value = "Not set" + start_image_value = ( + f"[View Start Image]({start_image_url})" if start_image_url else "Not set" + ) embed.add_field(name="Start Image", value=start_image_value, inline=False) await ctx.send(embed=embed) diff --git a/honeycombs/info.json b/honeycombs/info.json index 808901ac..aa97110b 100644 --- a/honeycombs/info.json +++ b/honeycombs/info.json @@ -13,7 +13,7 @@ "honeycombs", "Sugar honeycombs", "fun", - "enonomy" + "economy" ], "permissions": [ "embed_links", diff --git a/honeycombs/view.py b/honeycombs/view.py index fea95b5c..bbe0a054 100644 --- a/honeycombs/view.py +++ b/honeycombs/view.py @@ -24,6 +24,7 @@ import logging import random +from typing import Optional import discord from redbot.core import bank @@ -32,13 +33,15 @@ class JoinButton(discord.ui.Button): - def __init__(self, label: str): + def __init__(self, label: str, guild_id: int): super().__init__( - custom_id="join_honeycombs", label=label, style=discord.ButtonStyle.blurple + custom_id=f"join_honeycombs:{guild_id}", label=label, style=discord.ButtonStyle.blurple ) async def callback(self, interaction: discord.Interaction): - view: HoneycombView = self.view + if self.view is None or interaction.guild is None or interaction.message is None: + return + view: HoneycombView = self.view # type: ignore[assignment] game_state = view.cog.get_game_state(view.guild) guild_config = await view.cog.get_guild_config(view.guild) currency_name = await bank.get_currency_name(interaction.guild) @@ -97,6 +100,7 @@ def __init__(self, cog, guild): self.cog = cog self.guild = guild self.player_count = 0 + self.message: Optional[discord.Message] = None self.container = discord.ui.Container(accent_color=discord.Color.blurple()) self.container.add_item(discord.ui.Separator()) @@ -105,7 +109,7 @@ def __init__(self, cog, guild): self.game_details = discord.ui.TextDisplay("") self.container.add_item(self.game_details) self.container.add_item(discord.ui.Separator()) - self.join_button = JoinButton(label="Enter The Game (0/456)") + self.join_button = JoinButton(label="Enter The Game (0/456)", guild_id=guild.id) self.container.add_item(discord.ui.ActionRow(self.join_button)) self.container.add_item(discord.ui.Separator()) self.add_item(self.container) @@ -126,6 +130,8 @@ async def on_timeout(self): for child in self.walk_children(): if isinstance(child, discord.ui.Button): child.disabled = True + if self.message is None: + return try: await self.message.edit(view=self) except discord.HTTPException as e: From dac8398a231c52969b1201b824937e9dee71c54b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:15:51 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- messageguard/container.py | 2 +- messageguard/messageguard.py | 18 +++++++++--------- messageguard/utils.py | 4 ++++ pokemon/pokemon.py | 2 +- technews/technews.py | 2 +- technews/utils.py | 5 ++++- 6 files changed, 20 insertions(+), 13 deletions(-) diff --git a/messageguard/container.py b/messageguard/container.py index 8ad72729..9df53576 100644 --- a/messageguard/container.py +++ b/messageguard/container.py @@ -22,8 +22,8 @@ SOFTWARE. """ -from typing import Final import re +from typing import Final # ForwardDeleter FD_WARN_MESSAGE: Final[str] = "You are not allowed to forward message(s)." diff --git a/messageguard/messageguard.py b/messageguard/messageguard.py index 7e2357f0..ce602192 100644 --- a/messageguard/messageguard.py +++ b/messageguard/messageguard.py @@ -22,13 +22,13 @@ SOFTWARE. """ +import asyncio import re from asyncio import Lock from collections import defaultdict from typing import Any, Final import discord -import asyncio from red_commons.logging import getLogger from redbot.core import Config, commands from redbot.core.bot import Red @@ -36,6 +36,14 @@ from .commands.forward import ForwardCommands from .commands.restrict import RestrictCommands from .commands.spoiler import SpoilerCommands +from .container import ( + FD_WARN_MESSAGE, + NS_DEFAULT_WARNING, + RP_DEFAULT_MSG, + RP_DEFAULT_TITLE, + RP_URL_REGEX, + SPOILER_REGEX, +) from .utils import ( can_moderate, has_allowed_role, @@ -45,14 +53,6 @@ send_restrict_warning, send_spoiler_warning, ) -from .container import ( - FD_WARN_MESSAGE, - NS_DEFAULT_WARNING, - RP_DEFAULT_MSG, - RP_DEFAULT_TITLE, - RP_URL_REGEX, - SPOILER_REGEX, -) log = getLogger("red.maxcogs.messageguard") diff --git a/messageguard/utils.py b/messageguard/utils.py index 5334a4ff..76b25242 100644 --- a/messageguard/utils.py +++ b/messageguard/utils.py @@ -29,6 +29,7 @@ log = getLogger("red.maxcogs.messageguard.utils") + # idk why i did this but it makes the code cleaner in some places so here we are def has_manage_messages( channel: Union[discord.TextChannel, discord.Thread, discord.ForumChannel], @@ -64,8 +65,11 @@ def log_missing_permissions( guild.name, guild.id, ) + + # again here we are, this is just to clean up the code a bit and make it more readable in some places. + def is_forwarded_message(message: discord.Message) -> bool: reference = message.reference return reference is not None and reference.type == discord.MessageReferenceType.forward diff --git a/pokemon/pokemon.py b/pokemon/pokemon.py index 354e138f..ff405080 100644 --- a/pokemon/pokemon.py +++ b/pokemon/pokemon.py @@ -30,9 +30,9 @@ from redbot.core.bot import Red from redbot.core.utils.chat_formatting import humanize_list +from .commands.pokeinfo import PokeinfoCommands from .commands.tcgcard import TcgcardCommands from .commands.whosthatpokemon import WhosThatPokemonCommands -from .commands.pokeinfo import PokeinfoCommands log = getLogger("red.maxcogs.whosthatpokemon") diff --git a/technews/technews.py b/technews/technews.py index 632fa36f..fafe9359 100644 --- a/technews/technews.py +++ b/technews/technews.py @@ -34,8 +34,8 @@ from redbot.core import Config, commands from redbot.core.bot import Red +from .utils import ChannelOrThread, _can_post from .views import NewsLayout -from .utils import _can_post, ChannelOrThread log = getLogger("red.maxcogs.technews") diff --git a/technews/utils.py b/technews/utils.py index ea535ce9..636d63f1 100644 --- a/technews/utils.py +++ b/technews/utils.py @@ -22,11 +22,14 @@ SOFTWARE. """ -import discord from typing import Union +import discord + # Type alias for supported destinations ChannelOrThread = Union[discord.TextChannel, discord.Thread] + + def _can_post(me: discord.Member, channel: ChannelOrThread) -> bool: """ Check whether the bot can send messages and embed links in the given