From d507065184f919961bc19f03ff41b23a260edf3d Mon Sep 17 00:00:00 2001 From: Valerie Date: Sat, 24 May 2025 05:36:18 -0400 Subject: [PATCH] Refactor Shop Cog to improve inventory management and trading functionalities. Enhance shop management commands for better item handling and user interaction. Update documentation for clarity on new features and usage. --- casino/__init__.py | 9 + casino/cache.py | 30 + casino/casino.py | 1382 ++++++++++++++++++++++++++++++++++++++++++++ casino/data.py | 413 +++++++++++++ casino/deck.py | 117 ++++ casino/engine.py | 322 +++++++++++ casino/games.py | 592 +++++++++++++++++++ casino/info.json | 13 + casino/utils.py | 128 ++++ 9 files changed, 3006 insertions(+) create mode 100644 casino/__init__.py create mode 100644 casino/cache.py create mode 100644 casino/casino.py create mode 100644 casino/data.py create mode 100644 casino/deck.py create mode 100644 casino/engine.py create mode 100644 casino/games.py create mode 100644 casino/info.json create mode 100644 casino/utils.py diff --git a/casino/__init__.py b/casino/__init__.py new file mode 100644 index 0000000..8c20e8f --- /dev/null +++ b/casino/__init__.py @@ -0,0 +1,9 @@ +from .casino import Casino + +__red_end_user_data_statement__ = "This cog stores discord IDs as needed for operation." + + +async def setup(bot): + cog = Casino(bot) + await bot.add_cog(cog) + await cog.initialise() diff --git a/casino/cache.py b/casino/cache.py new file mode 100644 index 0000000..7715d2e --- /dev/null +++ b/casino/cache.py @@ -0,0 +1,30 @@ +from typing import Dict, Optional + +import discord +from redbot.core import Config + + +class OldMessageTypeManager: + def __init__(self, config: Config, enable_cache: bool = True): + self._config: Config = config + self.enable_cache = enable_cache + self._cached_guild: Dict[int, bool] = {} + + async def get_guild(self, guild: discord.Guild) -> bool: + ret: bool + gid: int = guild.id + if self.enable_cache and gid in self._cached_guild: + ret = self._cached_guild[gid] + else: + ret = await self._config.guild_from_id(gid).use_old_style() + self._cached_guild[gid] = ret + return ret + + async def set_guild(self, guild: discord.Guild, set_to: Optional[bool]) -> None: + gid: int = guild.id + if set_to is not None: + await self._config.guild_from_id(gid).use_old_style.set(set_to) + self._cached_guild[gid] = set_to + else: + await self._config.guild_from_id(gid).use_old_style.clear() + self._cached_guild[gid] = self._config.defaults["GUILD"]["use_old_style"] diff --git a/casino/casino.py b/casino/casino.py new file mode 100644 index 0000000..7fa0dd3 --- /dev/null +++ b/casino/casino.py @@ -0,0 +1,1382 @@ +# Developed by Redjumpman for Redbot. + +# Standard Library +import asyncio +import calendar +import logging +import re +from typing import Union, Final, Literal +from operator import itemgetter + + +# Casino +from . import utils +from .data import Database +from .games import Core, Blackjack, Double, War +from .utils import is_input_unsupported + +# Red +from redbot.core.i18n import Translator +from redbot.core import bank, commands, checks +from redbot.core.errors import BalanceTooHigh +from redbot.core.utils import AsyncIter +from redbot.core.utils.chat_formatting import box, humanize_number +from redbot.core.utils.predicates import MessagePredicate + +# Discord +import discord + +# Third-Party Libraries +from tabulate import tabulate + +__version__ = "2.6.0" +__author__ = "Redjumpman" + +_ = Translator("Casino", __file__) + +log = logging.getLogger("red.jumper-plugins.casino") +_SCHEMA_VERSION: Final[int] = 2 + + +class Casino(Database, commands.Cog): + __slots__ = ("bot", "cycle_task") + + def __init__(self, bot): + self.bot = bot + self.cycle_task = self.bot.loop.create_task(self.membership_updater()) + super().__init__() + + async def initialise(self): + self.migration_task = self.bot.loop.create_task( + self.data_schema_migration( + from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION + ) + ) + + async def red_delete_data_for_user( + self, *, requester: Literal["discord", "owner", "user", "user_strict"], user_id: int + ): + await super().config.user_from_id(user_id).clear() + all_members = await super().config.all_members() + async for guild_id, guild_data in AsyncIter(all_members.items(), steps=100): + if user_id in guild_data: + await super().config.member_from_ids(guild_id, user_id).clear() + + # -------------------------------------------------------------------------------------------------- + + @commands.command() + @commands.guild_only() + async def allin(self, ctx: commands.Context, multiplier: int): + """Bets all your currency for a chance to win big! + + The higher your multiplier the lower your odds of winning. + """ + if multiplier < 2: + return await ctx.send("Your multiplier must be 2 or higher.") + + bet = await bank.get_balance(ctx.author) + await Core(self.old_message_cache).play_allin(ctx, bet, multiplier) + + @commands.command(name="blackjack", aliases=["bj", "21"]) + @commands.guild_only() + async def _blackjack(self, ctx, bet: int): + """Play a game of blackjack. + + Blackjack supports doubling down, but not split. + """ + await Blackjack(self.old_message_cache).play(ctx, bet) + + @commands.command() + @commands.guild_only() + async def craps(self, ctx: commands.Context, bet: int): + """Plays a modified version of craps + + The player wins 7x their bet on a come-out roll of 7. + A comeout roll of 11 is an automatic win (standard mutlipliers apply). + The player will lose on a comeout roll of 2, 3, or 12. + Otherwise a point will be established. The player will keep + rolling until they hit a 7 (and lose) or their point number. + + Every bet is considered a 'Pass Line' bet. + """ + + await Core(self.old_message_cache).play_craps(ctx, bet) + + @commands.command() + @commands.guild_only() + async def coin(self, ctx: commands.Context, bet: int, choice: str): + """Coin flip game with a 50/50 chance to win. + + Pick heads or tails and place your bet. + """ + if choice.lower() not in ("heads", "tails", "h", "t"): + return await ctx.send("You must bet heads or tails.") + + await Core(self.old_message_cache).play_coin(ctx, bet, choice) + + @commands.command() + @commands.guild_only() + async def cups(self, ctx: commands.Context, bet: int, cup: str): + """Guess which cup of three is hiding the coin. + + Must pick 1, 2, or 3. + """ + await Core(self.old_message_cache).play_cups(ctx, bet, cup) + + @commands.command() + @commands.guild_only() + async def dice(self, ctx: commands.Context, bet: int): + """Roll a set of dice and win on 2, 7, 11, 12. + + Just place a bet. No need to pick a number. + """ + await Core(self.old_message_cache).play_dice(ctx, bet) + + @commands.command(aliases=["don", "x2"]) + @commands.guild_only() + async def double(self, ctx: commands.Context, bet: int): + """Play a game of Double Or Nothing. + + Continue to try to double your bet until + you cash out or lose it all. + """ + await Double(self.old_message_cache).play(ctx, bet) + + @commands.command(aliases=["hl"]) + @commands.guild_only() + async def hilo(self, ctx: commands.Context, bet: int, choice: str): + """Pick high, low, or 7 in a dice rolling game. + + Acceptable choices are high, hi, low, lo, 7, or seven. + """ + await Core(self.old_message_cache).play_hilo(ctx, bet, choice) + + @commands.command() + @commands.guild_only() + async def war(self, ctx: commands.Context, bet: int): + """Play a modified game of war.""" + await War(self.old_message_cache).play(ctx, bet) + + @commands.command(hidden=True) + @commands.is_owner() + async def bjmock(self, ctx, bet: int, *, hands: str): + """Test function for blackjack + + This will mock the blackjack game, allowing you to insert a player hand + and a dealer hand. + + Example: [p]bjmock 50 :clubs: 10, :diamonds: 10 | :clubs: Ace, :clubs: Queen + """ + ph, dh = hands.split(" | ") + ph = [(x[0], int(x[2:])) if x[2:].isdigit() else (x[0], x[2:]) for x in ph.split(", ")] + dh = [(x[0], int(x[2:])) if x[2:].isdigit() else (x[0], x[2:]) for x in dh.split(", ")] + await Blackjack(self.old_message_cache).mock(ctx, bet, ph, dh) + + # -------------------------------------------------------------------------------------------------- + + @commands.group() + @commands.guild_only() + async def casino(self, ctx): + """Interacts with the Casino system. + + Use help on Casino (upper case) for more commands. + """ + pass + + @casino.command() + async def memberships(self, ctx): + """Displays a list of server/global memberships.""" + data = await super().get_data(ctx) + settings = await data.all() + memberships = list(settings["Memberships"].keys()) + + if not memberships: + return await ctx.send(_("There are no memberships to display.")) + + await ctx.send( + _("Which of the following memberships would you like to know more about?\n`{}`").format( + utils.fmt_join(memberships) + ) + ) + + pred = MessagePredicate.contained_in(memberships, ctx=ctx) + + try: + membership = await ctx.bot.wait_for("message", timeout=25.0, check=pred) + except asyncio.TimeoutError: + return await ctx.send(_("No Response.")) + + games = settings["Games"] + perks = settings["Memberships"][membership.content] + playable = [x for x, y in games.items() if y["Access"] <= perks["Access"]] + + reqs = _("Credits: {Credits}\nRole: {Role}\nDays on Server: {DOS}").format(**perks) + color = utils.color_lookup(perks["Color"]) + desc = _( + "Access: {Access}\n" + "Cooldown Reduction: {Reduction} seconds\n" + "Bonus Multiplier: {Bonus}x\n" + "Color: {Color}" + ).format(**perks) + + info = _( + "Memberships are automatically assigned to players when they meet it's " + "requirements. If a player meets multiple membership requirements, they will be " + "assigned the one with the highest access level. If a membership is assigned " + "manually however, then the updater will skip that player until their membership " + "has been revoked." + ) + + # Embed + embed = discord.Embed(colour=color, description=desc) + embed.title = membership.content + embed.add_field(name=_("Playable Games"), value="\n".join(playable)) + embed.add_field(name=_("Requirements"), value=reqs) + embed.set_footer(text=info) + await ctx.send(embed=embed) + + @casino.command() + @checks.admin_or_permissions(administrator=True) + async def releasecredits(self, ctx, player: Union[discord.Member, discord.User]): + """Approves pending currency for a user. + + If this casino has maximum winnings threshold set, and a user makes a bet that + exceeds this amount, then they will have those credits with held. This command will + Allow you to release those credits back to the user. This system is designed to limit + earnings when a player may have found a way to cheat a game. + """ + + player_data = await super().get_data(ctx, player=player) + amount = await player_data.Pending_Credits() + + if amount <= 0: + return await ctx.send(_("This user doesn't have any credits pending.")) + + await ctx.send( + _("{} has {} credits pending. Would you like to release this amount?").format(player.name, amount) + ) + + pred = MessagePredicate.yes_or_no(ctx=ctx) + try: + choice = await ctx.bot.wait_for("message", timeout=25.0, check=pred) + except asyncio.TimeoutError: + return await ctx.send(_("No response. Action canceled.")) + + if choice.content.lower() == "yes": + try: + await bank.deposit_credits(player, amount) + await player_data.Pending_Credits.clear() + await ctx.send( + _( + "{0.mention} Your pending amount of {1} has been approved by " + "{2.name}, and was deposited into your account." + ).format(player, amount, ctx.author) + ) + except BalanceTooHigh as e: + await ctx.send( + _( + "{0.mention} Your pending amount of {1} has been approved by " + "{2.name}, but could not be deposited because your balance is at " + "the maximum amount of credits." + ).format(player, amount, ctx.author) + ) + else: + await ctx.send(_("Action canceled.")) + + @casino.command() + @checks.admin_or_permissions(administrator=True) + async def resetuser(self, ctx: commands.Context, user: discord.Member): + """Reset a user's cooldowns, stats, or everything.""" + + if await super().casino_is_global() and not await ctx.bot.is_owner(ctx.author): + return await ctx.send(_("While the casino is in global mode, only the bot owner may use this command.")) + + options = (_("cooldowns"), _("stats"), _("all")) + await ctx.send(_("What would you like to reset?\n`{}`.").format(utils.fmt_join(options))) + + pred = MessagePredicate.lower_contained_in(options, ctx=ctx) + try: + choice = await ctx.bot.wait_for("message", timeout=25.0, check=pred) + except asyncio.TimeoutError: + return await ctx.send(_("No response. Action canceled.")) + + if choice.content.lower() == _("cooldowns"): + await super()._reset_player_cooldowns(ctx, user) + elif choice.content.lower() == _("stats"): + await super()._reset_player_stats(ctx, user) + else: + await super()._reset_player_all(ctx, user) + + @casino.command() + @checks.admin_or_permissions(administrator=True) + async def resetinstance(self, ctx: commands.Context): + """Reset global/server cooldowns, settings, memberships, or everything.""" + if await super().casino_is_global() and not await ctx.bot.is_owner(ctx.author): + return await ctx.send(_("While the casino is in global mode, only the bot owner may use this command.")) + + options = (_("settings"), _("games"), _("cooldowns"), _("memberships"), _("all")) + await ctx.send(_("What would you like to reset?\n`{}`.").format(utils.fmt_join(options))) + pred = MessagePredicate.lower_contained_in(options, ctx=ctx) + await ctx.send(_("What would you like to reset?\n`{}`.").format(utils.fmt_join(options))) + + try: + choice = await ctx.bot.wait_for("message", timeout=25.0, check=pred) + except asyncio.TimeoutError: + return await ctx.send(_("No response. Action canceled.")) + + if choice.content.lower() == _("cooldowns"): + await super()._reset_cooldowns(ctx) + elif choice.content.lower() == _("settings"): + await super()._reset_settings(ctx) + elif choice.content.lower() == _("games"): + await super()._reset_games(ctx) + elif choice.content.lower() == _("memberships"): + await super()._reset_memberships(ctx) + else: + await super()._reset_all_settings(ctx) + + @casino.command() + @checks.is_owner() + async def wipe(self, ctx: commands.Context): + """Completely wipes casino data.""" + await ctx.send( + _( + "You are about to delete all casino and user data from the bot. Are you " + "sure this is what you wish to do?" + ) + ) + + pred = MessagePredicate.yes_or_no(ctx=ctx) + try: + choice = await ctx.bot.wait_for("message", timeout=25.0, check=pred) + except asyncio.TimeoutError: + return await ctx.send(_("No Response. Action canceled.")) + + if choice.content.lower() == "yes": + return await super()._wipe_casino(ctx) + else: + return await ctx.send(_("Wipe canceled.")) + + @casino.command() + @checks.admin_or_permissions(administrator=True) + async def admin(self, ctx: commands.Context): + """A list of Admin level and above commands for Casino.""" + cmd_list = [] + cmd_list2 = [] + for cmd in ctx.bot.get_command("casino").commands: + if cmd.requires.privilege_level.name == "ADMIN": + if await cmd.requires.verify(ctx): + cmd_list.append((cmd.qualified_name, cmd.short_doc)) + + for cmd in ctx.bot.get_command("casinoset").commands: + if await cmd.requires.verify(ctx): + cmd_list2.append((cmd.qualified_name, cmd.short_doc)) + cmd_list = "\n".join(["**{}** - {}".format(x, y) for x, y in cmd_list]) + cmd_list2 = "\n".join(["**{}** - {}".format(x, y) for x, y in cmd_list2]) + wiki = "[Casino Wiki](https://github.com/Redjumpman/Jumper-Plugins/wiki/Casino-RedV3)" + embed = discord.Embed(colour=0xFF0000, description=wiki) + embed.set_author(name="Casino Admin Panel", icon_url=ctx.bot.user.display_avatar) + embed.add_field(name="__Casino__", value=cmd_list) + embed.add_field(name="__Casino Settings__", value=cmd_list2) + embed.set_footer(text=_("With great power, comes great responsibility.")) + await ctx.send(embed=embed) + + @casino.command() + async def info(self, ctx: commands.Context): + """Shows information about Casino. + + Displays a list of games with their set parameters: + Access Levels, Maximum and Minimum bets, if it's open to play, + cooldowns, and multipliers. It also displays settings for the + server (or global) if enabled. + """ + instance = await super().get_data(ctx) + settings = await instance.Settings.all() + game_data = await instance.Games.all() + + t = sorted( + [ + [x] + [b for a, b in sorted(y.items(), key=itemgetter(0)) if a != "Cooldown"] + for x, y in game_data.items() + ] + ) + cool = [ + utils.cooldown_formatter(y["Cooldown"]) + for x, y in sorted(game_data.items(), key=itemgetter(0)) + ] + table = [x + [y] for x, y in zip(t, cool)] + + headers = (_("Game"), _("Access"), _("Max"), _("Min"), _("Payout"), _("On"), _("CD")) + t = tabulate(table, headers=headers) + msg = _( + "{}\n\n" + "Casino Name: {Casino_Name} Casino\n" + "Casino Open: {Casino_Open}\n" + "Global: {Global}\n" + "Payout Limit ON: {Payout_Switch}\n" + "Payout Limit: {Payout_Limit}" + ).format(t, **settings) + await ctx.send(box(msg, lang="cpp")) + + @casino.command() + async def stats( + self, ctx: commands.Context, player: Union[discord.Member, discord.User] = None + ): + """Shows your play statistics for Casino""" + if player is None: + player = ctx.author + + casino = await super().get_data(ctx) + casino_name = await casino.Settings.Casino_Name() + + coro = await super().get_data(ctx, player=player) + player_data = await coro.all() + + mem, perks = await super()._get_player_membership(ctx, player) + color = utils.color_lookup(perks["Color"]) + + games = sorted(await casino.Games.all()) + played = [y for x, y in sorted(player_data["Played"].items(), key=itemgetter(0))] + won = [y for x, y in sorted(player_data["Won"].items(), key=itemgetter(0))] + cool_items = [y for x, y in sorted(player_data["Cooldowns"].items(), key=itemgetter(0))] + + reduction = perks["Reduction"] + fmt_reduct = utils.cooldown_formatter(reduction) + cooldowns = self.parse_cooldowns(ctx, cool_items, reduction) + description = _( + "Membership: {0}\nAccess Level: {Access}\nCooldown Reduction: {1}\nBonus Multiplier: {Bonus}x" + ).format(mem, fmt_reduct, **perks) + + headers = ("Games", "Played", "Won", "Cooldowns") + table = tabulate(zip(games, played, won, cooldowns), headers=headers) + disclaimer = _("Wins do not take into calculation pushed bets or surrenders.") + + # Embed + embed = discord.Embed(colour=color, description=description) + embed.title = _("{} Casino").format(casino_name) + embed.set_author(name=str(player), icon_url=player.avatar.url) + embed.add_field(name="\u200b", value="\u200b") + embed.add_field(name="-" * 65, value=box(table, lang="md")) + embed.set_footer(text=disclaimer) + await ctx.send(embed=embed) + + @casino.command() + async def version(self, ctx: commands.Context): + """Shows the current Casino version.""" + await ctx.send("Casino is running version {}.".format(__version__)) + + # -------------------------------------------------------------------------------------------------- + + async def global_casino_only(ctx): + if await ctx.cog.config.Settings.Global() and not await ctx.bot.is_owner(ctx.author): + return False + else: + return True + + @commands.check(global_casino_only) + @commands.group() + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + async def casinoset(self, ctx: commands.Context): + """Changes Casino settings""" + pass + + @casinoset.command() + @checks.admin_or_permissions(administrator=True) + async def assignmem( + self, + ctx: commands.Context, + player: Union[discord.Member, discord.User], + *, + membership: str, + ): + """Manually assigns a membership to a user. + + Users who are assigned a membership no longer need to meet the + requirements set. However, if the membership is revoked, then the + user will need to meet the requirements as usual. + + """ + settings = await super().get_data(ctx) + memberships = await settings.Memberships.all() + if membership not in memberships: + return await ctx.send(_("{} is not a registered membership.").format(membership)) + + player_instance = await super().get_data(ctx, player=player) + await player_instance.Membership.set({"Name": membership, "Assigned": True}) + + msg = _("{0.name} ({0.id}) manually assigned {1.name} ({1.id}) the {2} membership.").format( + ctx.author, player, membership + ) + await ctx.send(msg) + + @casinoset.command() + @checks.admin_or_permissions(administrator=True) + async def revokemem(self, ctx: commands.Context, player: Union[discord.Member, discord.User]): + """Revoke an assigned membership. + + Members will still keep this membership until the next auto cycle (5mins). + At that time, they will be re-evaluated and downgraded/upgraded appropriately. + """ + player_data = await super().get_data(ctx, player=player) + + if not await player_data.Membership.Assigned(): + return await ctx.send(_("{} has no assigned membership.").format(player.name)) + else: + await player_data.Membership.set({"Name": "Basic", "Assigned": False}) + return await ctx.send( + _( + "{} has unassigned {}'s membership. They have been set " + "to `Basic` until the next membership update cycle." + ).format(ctx.author.name, player.name) + ) + + @casinoset.command() + @commands.max_concurrency(1, commands.BucketType.guild) + @checks.admin_or_permissions(administrator=True) + async def memdesigner(self, ctx: commands.Context): + """A process to create, edit, and delete memberships.""" + timeout = ctx.send(_("Process timed out. Exiting membership process.")) + + await ctx.send(_("Do you wish to `create`, `edit`, or `delete` an existing membership?")) + + pred = MessagePredicate.lower_contained_in(("edit", "create", "delete"), ctx=ctx) + try: + choice = await ctx.bot.wait_for("Message", timeout=25.0, check=pred) + except asyncio.TimeoutError: + return await timeout + + await Membership(ctx, timeout, choice.content.lower()).process() + + @casinoset.command(name="oldstyle") + async def change_style(self, ctx: commands.Context): + """Toggle between editing and sending new messages for casino games..""" + + current = await self.old_message_cache.get_guild(guild=ctx.guild) + await self.old_message_cache.set_guild(guild=ctx.guild, set_to=not current) + + await ctx.send( + _("Casino message type set to {type}.").format( + type=_("**edit existing message**") if current else _("**send new message**") + ) + ) + + @casinoset.command(name="mode") + @checks.is_owner() + async def mode(self, ctx: commands.Context): + """Toggles Casino between global and local modes. + + When casino is set to local mode, each server will have its own + unique data, and admin level commands can be used on that server. + + When casino is set to global mode, data is linked between all servers + the bot is connected to. In addition, admin level commands can only be + used by the owner or co-owners. + """ + + mode = "global" if await super().casino_is_global() else "local" + alt = "local" if mode == "global" else "global" + await ctx.send( + _("Casino is currently set to {} mode. Would you like to change to {} mode instead?").format(mode, alt) + ) + pred = MessagePredicate.yes_or_no(ctx=ctx) + + try: + choice = await ctx.bot.wait_for("message", timeout=25.0, check=pred) + except asyncio.TimeoutError: + return await ctx.send(_("No response. Action canceled.")) + if choice.content.lower() != _("yes"): + return await ctx.send(_("Casino will remain {}.").format(mode)) + + await ctx.send( + _( + "Changing casino to {0} will **DELETE ALL** current casino data. Are " + "you sure you wish to make casino {0}?" + ).format(alt) + ) + try: + final = await ctx.bot.wait_for("message", timeout=25.0, check=pred) + except asyncio.TimeoutError: + return await ctx.send(_("No response. Action canceled.")) + + if final.content.lower() == _("yes"): + if not await bank.is_global() and alt == "global": + return await ctx.send( + "You cannot make casino global while economy is " + "in local mode. To change your economy to global " + "use `{}bankset toggleglobal`".format(ctx.prefix) + ) + await super().change_mode(alt) + await ctx.send(_("Casino data deleted! Casino mode now set to {}.").format(alt)) + else: + await ctx.send(_("Casino will remain {}.").format(mode)) + + @casinoset.command() + async def payoutlimit(self, ctx: commands.Context, limit: int): + """Sets a payout limit. + + Users who exceed this amount will have their winnings witheld until they are + reviewed and approved by the appropriate authority. Limits are only triggered if + payout limits are ON. To turn on payout limits, use payouttoggle. + """ + + if limit < 0 or is_input_unsupported(limit): + return await ctx.send(_("Go home. You're drunk.")) + + settings = await super().get_data(ctx) + await settings.Settings.Payout_Limit.set(limit) + msg = _("{0.name} ({0.id}) set the payout limit to {1}.").format(ctx.author, limit) + await ctx.send(msg) + + @casinoset.command() + async def payouttoggle(self, ctx: commands.Context): + """Turns on a payout limit. + + The payout limit will withhold winnings from players until they are approved by the + appropriate authority. To set the limit, use payoutlimit. + """ + settings = await super().get_data(ctx) + status = await settings.Settings.Payout_Switch() + await settings.Settings.Payout_Switch.set(not status) + msg = _("{0.name} ({0.id}) turned the payout limit {1}.").format(ctx.author, "OFF" if status else "ON") + await ctx.send(msg) + + @casinoset.command() + async def toggle(self, ctx: commands.Context): + """Opens and closes the Casino for use. + + This command only restricts the use of playing games. + """ + settings = await super().get_data(ctx) + name = await settings.Settings.Casino_Name() + + status = await settings.Settings.Casino_Open() + await settings.Settings.Casino_Open.set(not status) + msg = _("{0.name} ({0.id}) {2} the {1} Casino.").format(ctx.author, name, "closed" if status else "opened") + await ctx.send(msg) + + @casinoset.command() + async def name(self, ctx: commands.Context, *, name: str): + """Sets the name of the Casino. + + The casino name may only be 30 characters in length. + """ + if len(name) > 30: + return await ctx.send(_("Your Casino name must be 30 characters or less.")) + + settings = await super().get_data(ctx) + await settings.Settings.Casino_Name.set(name) + msg = _("{0.name} ({0.id}) set the casino name to {1}.").format(ctx.author, name) + await ctx.send(msg) + + @casinoset.command() + async def multiplier(self, ctx: commands.Context, game: str, multiplier: float): + """Sets the payout multiplier for a game. + """ + settings = await super().get_data(ctx) + games = await settings.Games.all() + if is_input_unsupported(multiplier): + return await ctx.send(_("Go home. You're drunk.")) + + if game.title() == "Allin" or game.title() == "Double": + return await ctx.send(_("This games's multiplier is determined by the user.")) + + if not await self.basic_check(ctx, game, games, multiplier): + return + + await settings.Games.set_raw(game.title(), "Multiplier", value=multiplier) + msg = _("{0.name} ({0.id}) set {1}'s multiplier to {2}.").format(ctx.author, game.title(), multiplier) + if multiplier == 0: + msg += _( + "\n\nWait a minute...Zero?! Really... I'm a bot and that's more " + "heartless than me! ... who hurt you human?" + ) + await ctx.send(msg) + + @casinoset.command() + async def cooldown(self, ctx: commands.Context, game: str, cooldown: str): + """Sets the cooldown for a game. + + You can use the format DD:HH:MM:SS to set a time, or just simply + type the number of seconds. + """ + settings = await super().get_data(ctx) + games = await settings.Games.all() + + try: + seconds = utils.time_converter(cooldown) + except ValueError: + return await ctx.send(_("Invalid cooldown format. Must be an integer or in HH:MM:SS style.")) + + if seconds < 0: + return await ctx.send(_("Nice try McFly, but this isn't Back to the Future.")) + + if game.title() not in games: + return await ctx.send( + _("Invalid game name. Must be one of the following:\n`{}`.").format(utils.fmt_join(list(games))) + ) + + await settings.Games.set_raw(game.title(), "Cooldown", value=seconds) + cool = utils.cooldown_formatter(seconds) + msg = _("{0.name} ({0.id}) set {1}'s cooldown to {2}.").format(ctx.author, game.title(), cool) + await ctx.send(msg) + + @casinoset.command(name="min") + async def _min(self, ctx: commands.Context, game: str, minimum: int): + """Sets the minimum bid for a game.""" + settings = await super().get_data(ctx) + games = await settings.Games.all() + + if not await self.basic_check(ctx, game, games, minimum): + return + + if is_input_unsupported(minimum): + return await ctx.send(_("Go home. You're drunk.")) + + if game.title() == "Allin": + return await ctx.send(_("You cannot set a minimum bid for Allin.")) + + if minimum > games[game.title()]["Max"]: + return await ctx.send(_("You can't set a minimum higher than the game's maximum bid.")) + + await settings.Games.set_raw(game.title(), "Min", value=minimum) + msg = _("{0.name} ({0.id}) set {1}'s minimum bid to {2}.").format(ctx.author, game.title(), minimum) + await ctx.send(msg) + + @casinoset.command(name="max") + async def _max(self, ctx: commands.Context, game: str, maximum: int): + """Sets the maximum bid for a game.""" + settings = await super().get_data(ctx) + games = await settings.Games.all() + + if not await self.basic_check(ctx, game, games, maximum): + return + + if is_input_unsupported(maximum): + return await ctx.send(_("Go home. You're drunk.")) + + if game.title() == "Allin": + return await ctx.send(_("You cannot set a maximum bid for Allin.")) + + if maximum < games[game.title()]["Min"]: + return await ctx.send(_("You can't set a maximum lower than the game's minimum bid.")) + + await settings.Games.set_raw(game.title(), "Max", value=maximum) + msg = _("{0.name} ({0.id}) set {1}'s maximum bid to {2}.").format(ctx.author, game.title(), maximum) + await ctx.send(msg) + + @casinoset.command() + async def access(self, ctx, game: str, access: int): + """Sets the access level required to play a game. + + Access levels are used in conjunction with memberships. To read more on using + access levels and memberships please refer to the casino wiki.""" + data = await super().get_data(ctx) + games = await data.Games.all() + + if not await self.basic_check(ctx, game, games, access): + return + + if is_input_unsupported(access): + return await ctx.send(_("Go home. You're drunk.")) + + await data.Games.set_raw(game.title(), "Access", value=access) + msg = _("{0.name} ({0.id}) changed the access level for {1} to {2}.").format(ctx.author, game, access) + await ctx.send(msg) + + @casinoset.command() + async def gametoggle(self, ctx, game: str): + """Opens/Closes a specific game for use.""" + instance = await super().get_data(ctx) + games = await instance.Games.all() + if game.title() not in games: + return await ctx.send("Invalid game name.") + + status = await instance.Games.get_raw(game.title(), "Open") + await instance.Games.set_raw(game.title(), "Open", value=(not status)) + msg = _("{0.name} ({0.id}) {2} the game {1}.").format(ctx.author, game, "closed" if status else "opened") + await ctx.send(msg) + + # -------------------------------------------------------------------------------------------------- + + async def membership_updater(self): + await self.bot.wait_until_red_ready() + try: + while True: + await asyncio.sleep(300) # Wait 5 minutes to cycle again + is_global = await super().casino_is_global() + if is_global: + await self.global_updater() + else: + await self.local_updater() + except Exception: + log.error("Casino error in membership_updater:\n", exc_info=True) + + async def global_updater(self): + while True: + users = await self.config.all_users() + if not users: + break + memberships = await self.config.Memberships.all() + if not memberships: + break + for user in users: + user_obj = self.bot.get_user(user) + if not user_obj: + # user isn't in the cache so we can probably + # ignore them without issue + continue + async with self.config.user(user_obj).Membership() as user_data: + if user_data["Name"] not in memberships: + user_data["Name"] = "Basic" + user_data["Assigned"] = False + await self.process_user(memberships, user_obj, _global=True) + break + + async def local_updater(self): + while True: + guilds = await self.config.all_guilds() + if not guilds: + break + for guild in guilds: + guild_obj = self.bot.get_guild(guild) + if not guild_obj: + continue + users = await self.config.all_members(guild_obj) + if not users: + continue + memberships = await self.config.guild(guild_obj).Memberships.all() + if not memberships: + continue + for user in users: + user_obj = guild_obj.get_member(user) + if not user_obj: + continue + async with self.config.member(user_obj).Membership() as user_data: + if user_data["Name"] not in memberships: + user_data["Name"] = "Basic" + user_data["Assigned"] = False + await self.process_user(memberships, user_obj) + break + + async def process_user(self, memberships, user, _global=False): + qualified = [] + try: + bal = await bank.get_balance(user) + except AttributeError: + log.error( + "Casino is in global mode, while economy is in local mode. " + "Economy must be global if Casino is global. Either change casino " + "back to local with the casinoset mode command or make your economy " + "global with the bankset toggleglobal command." + ) + return + for name, requirements in memberships.items(): + if _global: + if requirements["Credits"] and bal < requirements["Credits"]: + continue + elif ( + requirements["DOS"] + and requirements["DOS"] > (user.created_at.now() - user.created_at).days + ): + continue + else: + qualified.append((name, requirements["Access"])) + else: + role_list = [x.name for x in user.roles] + role_list += [x.mention for x in user.roles] + if requirements["Credits"] and bal < requirements["Credits"]: + continue + elif requirements["Role"] and requirements["Role"] not in role_list: + continue + elif ( + requirements["DOS"] + and requirements["DOS"] > (user.joined_at.now() - user.joined_at).days + ): + continue + else: + qualified.append((name, requirements["Access"])) + + membership = max(qualified, key=itemgetter(1))[0] if qualified else "Basic" + if _global: + async with self.config.user(user).Membership() as data: + data["Name"] = membership + data["Assigned"] = False + else: + async with self.config.member(user).Membership() as data: + data["Name"] = membership + data["Assigned"] = False + + @staticmethod + async def basic_check(ctx, game, games, base): + if game.title() not in games: + await ctx.send( + "Invalid game name. Must be on of the following:\n`{}`".format(utils.fmt_join(list(games))) + ) + return False + elif base < 0: + await ctx.send(_("Go home. You're drunk.")) + return False + else: + return True + + @staticmethod + def parse_cooldowns(ctx, cooldowns, reduction): + now = calendar.timegm(ctx.message.created_at.utctimetuple()) + results = [] + for cooldown in cooldowns: + seconds = int((cooldown + reduction - now)) + results.append(utils.cooldown_formatter(seconds, custom_msg="")) + return results + + def cog_unload(self): + self.__unload() + + def __unload(self): + self.cycle_task.cancel() + if self.migration_task: + self.migration_task.cancel() + + async def cog_before_invoke(self, ctx: commands.Context) -> None: + if not self.cog_ready_event.is_set(): + async with ctx.typing(): + await self.cog_ready_event.wait() + + +class Membership(Database): + """This class handles membership processing.""" + + __slots__ = ("ctx", "timeout", "cancel", "mode", "coro") + + colors = { + _("blue"): "blue", + _("red"): "red", + _("green"): "green", + _("orange"): "orange", + _("purple"): "purple", + _("yellow"): "yellow", + _("turquoise"): "turquoise", + _("teal"): "teal", + _("magenta"): "magenta", + _("pink"): "pink", + _("white"): "white", + } + + requirements = (_("days on server"), _("credits"), _("role")) + + def __init__(self, ctx, timeout, mode): + self.ctx = ctx + self.timeout = timeout + self.cancel = ctx.prefix + _("cancel") + self.mode = mode + self.coro = None + super().__init__() + + def switcher(self): + if self.mode == "edit": + return self.editor + elif self.mode == "create": + return self.creator + else: + return self.delete + + async def process(self): + action = self.switcher() + instance = await super().get_data(self.ctx) + self.coro = instance.Memberships + try: + await action() + except asyncio.TimeoutError: + await self.timeout + except ExitProcess: + await self.ctx.send(_("Process exited.")) + + async def delete(self): + memberships = await self.coro.all() + + def mem_check(m): + valid_name = m.content + return ( + m.author == self.ctx.author + and valid_name in memberships + or valid_name == self.cancel + ) + + if not memberships: + await self.ctx.send(_("There are no memberships to delete.")) + raise ExitProcess() + + await self.ctx.send( + _("Which membership would you like to delete?\n`{}`").format(utils.fmt_join(list(memberships.keys()))) + ) + membership = await self.ctx.bot.wait_for("message", timeout=25.0, check=mem_check) + + if membership.content == self.cancel: + raise ExitProcess() + await self.ctx.send( + _("Are you sure you wish to delete `{}`? This cannot be reverted.").format(membership.content) + ) + + choice = await self.ctx.bot.wait_for( + "message", timeout=25.0, check=MessagePredicate.yes_or_no(ctx=self.ctx) + ) + if choice.content.lower() == self.cancel: + raise ExitProcess() + elif choice.content.lower() == "yes": + name = membership.content + async with self.coro() as data: + del data[name] + await self.ctx.send(_("{} has been deleted.").format(membership.content)) + else: + await self.ctx.send(_("Deletion canceled.")) + + async def creator(self): + + await self.ctx.send( + _( + "You are about to create a new membership. You may exit this " + "process at any time by typing `{}cancel`." + ).format(self.ctx.prefix) + ) + + data = dict.fromkeys(("Access", "Bonus", "Color", "Credits", "Role", "DOS", "Reduction")) + + name, valid_name = await self.set_name() + await self.set_access(data) + await self.set_color(data) + await self.set_reduction(data) + await self.set_bonus(data) + await self.req_loop(data) + + async with self.coro() as mem: + mem[valid_name] = data + embed = self.build_embed(name, data) + await self.ctx.send(embed=embed) + raise ExitProcess() + + async def editor(self): + memberships = await self.coro.all() + + def mem_check(m): + return ( + m.author == self.ctx.author + and m.content in memberships + or m.content == self.cancel + ) + + if not memberships: + await self.ctx.send(_("There are no memberships to edit.")) + raise ExitProcess() + + await self.ctx.send( + _("Which of the following memberships would you like to edit?\n`{}`").format( + utils.fmt_join(list(memberships.keys())) + ) + ) + + membership = await self.ctx.bot.wait_for("message", timeout=25.0, check=mem_check) + if membership.content == self.cancel: + raise ExitProcess() + + attrs = (_("Requirements"), _("Name"), _("Access"), _("Color"), _("Reduction"), _("Bonus")) + await self.ctx.send( + _("Which of the following attributes would you like to edit?\n`{}`").format(utils.fmt_join(attrs)) + ) + + pred = MessagePredicate.lower_contained_in( + ( + _("requirements"), + _("access"), + _("color"), + _("name"), + _("reduction"), + _("bonus"), + self.cancel, + ), + ctx=self.ctx, + ) + attribute = await self.ctx.bot.wait_for("message", timeout=25.0, check=pred) + + valid_name = membership.content + if attribute.content.lower() == self.cancel: + raise ExitProcess() + elif attribute.content.lower() == _("requirements"): + await self.req_loop(valid_name) + elif attribute.content.lower() == _("access"): + await self.set_access(valid_name) + elif attribute.content.lower() == _("bonus"): + await self.set_bonus(valid_name) + elif attribute.content.lower() == _("reduction"): + await self.set_reduction(valid_name) + elif attribute.content.lower() == _("color"): + await self.set_color(valid_name) + elif attribute.content.lower() == _("name"): + await self.set_name(valid_name) + else: + await self.set_color(valid_name) + + await self.ctx.send(_("Would you like to edit another membership?")) + + choice = await self.ctx.bot.wait_for( + "message", timeout=25.0, check=MessagePredicate.yes_or_no(ctx=self.ctx) + ) + if choice.content.lower() == _("yes"): + await self.editor() + else: + raise ExitProcess() + + async def set_color(self, membership): + await self.ctx.send(_("What color would you like to set?\n`{}`").format(utils.fmt_join(list(self.colors)))) + + color_list = list(self.colors) + color_list.append(str(self.cancel)) + pred = MessagePredicate.lower_contained_in(color_list, ctx=self.ctx) + color = await self.ctx.bot.wait_for("message", timeout=25.0, check=pred) + + if color.content.lower() == self.cancel: + raise ExitProcess() + + if self.mode == "create": + membership["Color"] = color.content.lower() + return + + async with self.coro() as membership_data: + membership_data[membership]["Color"] = color.content.lower() + + await self.ctx.send(_("Color set to {}.").format(color.content.lower())) + + async def set_name(self, membership=None): + memberships = await self.coro.all() + + def mem_check(m): + if not m.channel == self.ctx.channel and m.author == self.ctx.author: + return False + if m.author == self.ctx.author: + if m.content == self.cancel: + raise ExitProcess + conditions = ( + m.content not in memberships, + (True if re.match("^[a-zA-Z0-9 -]*$", m.content) else False), + ) + if all(conditions): + return True + else: + return False + else: + return False + + await self.ctx.send(_("What name would you like to set?")) + name = await self.ctx.bot.wait_for("message", timeout=25.0, check=mem_check) + + if name.content == self.cancel: + raise ExitProcess() + + valid_name = name.content + if self.mode == "create": + return name.content, valid_name + + async with self.coro() as membership_data: + membership_data[valid_name] = membership_data.pop(membership) + + await self.ctx.send(_("Name set to {}.").format(name.content)) + + async def set_access(self, membership): + await self.ctx.send(_("What access level would you like to set?")) + access = await self.ctx.bot.wait_for( + "message", timeout=25.0, check=self.positive_int_predicate + ) + + user_input = int(access.content) + if is_input_unsupported(user_input): + await self.ctx.send(_("Can't set the reduction to this value.")) + return + + if self.mode == "create": + membership["Access"] = user_input + return + + async with self.coro() as membership_data: + membership_data[membership]["Access"] = user_input + + await self.ctx.send(_("Access set to {}.").format(user_input)) + + async def set_reduction(self, membership): + await self.ctx.send(_("What is the cooldown reduction of this membership in seconds?")) + reduction = await self.ctx.bot.wait_for( + "message", timeout=25.0, check=self.positive_int_predicate + ) + + user_input = int(reduction.content) + if is_input_unsupported(user_input): + await self.ctx.send(_("Can't set the reduction to this value.")) + return + + if self.mode == "create": + membership["Reduction"] = user_input + return + + async with self.coro() as membership_data: + membership_data[membership]["Reduction"] = user_input + + async def set_bonus(self, membership): + await self.ctx.send(_("What is the bonus payout multiplier for this membership?\n*Defaults to 1.0*")) + bonus = await self.ctx.bot.wait_for("message", timeout=25.0, check=self.positive_float_predicate) + + if bonus.content.lower() == self.cancel: + raise ExitProcess + user_input = bonus.content + if is_input_unsupported(user_input): + await self.ctx.send(_("Can't set the bonus multiplier to this value.")) + return + + if self.mode == "create": + membership["Bonus"] = float(user_input) + return + + async with self.coro() as membership_data: + membership_data[membership]["Bonus"] = float(bonus.content) + + await self.ctx.send(_("Bonus multiplier set to {}.").format(bonus.content)) + + async def req_loop(self, membership): + while True: + await self.ctx.send( + _("Which requirement would you like to add or modify?\n`{}`").format( + utils.fmt_join(self.requirements) + ) + ) + + pred = MessagePredicate.lower_contained_in( + (_("credits"), _("role"), _("dos"), _("days on server"), self.cancel), ctx=self.ctx + ) + + req = await self.ctx.bot.wait_for("message", timeout=25.0, check=pred) + if req.content.lower() == self.cancel: + raise ExitProcess() + elif req.content.lower() == _("credits"): + await self.credits_requirement(membership) + elif req.content.lower() == _("role"): + await self.role_requirement(membership) + else: + await self.dos_requirement(membership) + + await self.ctx.send(_("Would you like to continue adding or modifying requirements?")) + + choice = await self.ctx.bot.wait_for( + "message", timeout=25.0, check=MessagePredicate.yes_or_no(ctx=self.ctx) + ) + if choice.content.lower() == _("no"): + break + elif choice.content.lower() == self.cancel: + raise ExitProcess() + else: + continue + + async def credits_requirement(self, membership): + await self.ctx.send(_("How many credits does this membership require?")) + + amount = await self.ctx.bot.wait_for( + "message", timeout=25.0, check=self.positive_int_predicate + ) + + amount = int(amount.content) + if is_input_unsupported(amount): + await self.ctx.send(_("Can't set the credit requirement to this value.")) + return + if self.mode == "create": + membership["Credits"] = amount + return + + async with self.coro() as membership_data: + membership_data[membership]["Credits"] = amount + + await self.ctx.send(_("Credits requirement set to {}.").format(humanize_number(amount))) + + async def role_requirement(self, membership): + await self.ctx.send( + _( + "What role does this membership require?\n" + "*Note this is skipped in global mode. If you set this as the only " + "requirement in global, it will be accessible to everyone!*" + ) + ) + pred = MessagePredicate.valid_role(ctx=self.ctx) + role = await self.ctx.bot.wait_for("message", timeout=25.0, check=pred) + + if self.mode == "create": + membership["Role"] = role.content + return + + async with self.coro() as membership_data: + membership_data[membership]["Role"] = role.content + + await self.ctx.send(_("Role requirement set to {}.").format(role.content)) + + async def dos_requirement(self, membership): + await self.ctx.send( + _( + "How many days on server does this membership require?\n" + "*Note in global mode this will calculate based on when the user " + "account was created.*" + ) + ) + days = await self.ctx.bot.wait_for( + "message", timeout=25.0, check=self.positive_int_predicate + ) + + if self.mode == "create": + membership["DOS"] = int(days.content) + return + + async with self.coro() as membership_data: + membership_data[membership]["DOS"] = int(days.content) + await self.ctx.send(_("Time requirement set to {}.").format(days.content)) + + @staticmethod + def build_embed(name, data): + description = _( + "Membership sucessfully created.\n\n" + "**Name:** {0}\n" + "**Access:** {Access}\n" + "**Bonus:** {Bonus}x\n" + "**Reduction:** {Reduction} seconds\n" + "**Color:** {Color}\n" + "**Credits Required:** {Credits}\n" + "**Role Required:** {Role}\n" + "**Days on Server/Discord Required:** {DOS}" + ).format(name, **data) + return discord.Embed(colour=0x2CD22C, description=description) + + def positive_int_predicate(self, m: discord.Message): + if not m.channel == self.ctx.channel and m.author == self.ctx.author: + return False + if m.author == self.ctx.author: + if m.content == self.cancel: + raise ExitProcess + try: + int(m.content) + except ValueError: + return False + if int(m.content) < 1: + return False + else: + return True + + def positive_float_predicate(self, m: discord.Message): + if not m.channel == self.ctx.channel and m.author == self.ctx.author: + return False + if m.author == self.ctx.author: + if m.content == self.cancel: + raise ExitProcess + try: + float(m.content) + except ValueError: + return False + if float(m.content) > 0: + return True + else: + return False + + +class ExitProcess(Exception): + pass diff --git a/casino/data.py b/casino/data.py new file mode 100644 index 0000000..6bd9cb6 --- /dev/null +++ b/casino/data.py @@ -0,0 +1,413 @@ +import asyncio +import logging +from copy import deepcopy + +import discord +from redbot.core import Config, bank +from collections import namedtuple + +from .cache import OldMessageTypeManager +from .utils import is_input_unsupported, min_int, max_int + +user_defaults = { + "Pending_Credits": 0, + "Membership": {"Name": "Basic", "Assigned": False}, + "Played": { + "Allin": 0, + "Blackjack": 0, + "Coin": 0, + "Craps": 0, + "Cups": 0, + "Dice": 0, + "Hilo": 0, + "War": 0, + "Double": 0, + }, + "Won": { + "Allin": 0, + "Blackjack": 0, + "Coin": 0, + "Craps": 0, + "Cups": 0, + "Dice": 0, + "Hilo": 0, + "War": 0, + "Double": 0, + }, + "Cooldowns": { + "Allin": 0, + "Blackjack": 0, + "Coin": 0, + "Craps": 0, + "Cups": 0, + "Dice": 0, + "Hilo": 0, + "War": 0, + "Double": 0, + }, +} + +guild_defaults = { + "use_old_style": False, + "Memberships": {}, + "Settings": { + "Global": False, + "Casino_Name": "Redjumpman's", + "Casino_Open": True, + "Payout_Switch": False, + "Payout_Limit": 10000, + }, + "Games": { + "Allin": { + "Access": 0, + "Cooldown": 43200, + "Min": None, + "Max": None, + "Multiplier": None, + "Open": True, + }, + "Blackjack": { + "Access": 0, + "Cooldown": 5, + "Min": 50, + "Max": 500, + "Multiplier": 2.0, + "Open": True, + }, + "Coin": { + "Access": 0, + "Cooldown": 5, + "Max": 10, + "Min": 10, + "Multiplier": 1.5, + "Open": True, + }, + "Craps": { + "Access": 0, + "Cooldown": 5, + "Max": 500, + "Min": 50, + "Multiplier": 2.0, + "Open": True, + }, + "Cups": { + "Access": 0, + "Cooldown": 5, + "Max": 100, + "Min": 25, + "Multiplier": 1.8, + "Open": True, + }, + "Dice": { + "Access": 0, + "Cooldown": 5, + "Max": 100, + "Min": 25, + "Multiplier": 1.8, + "Open": True, + }, + "Hilo": { + "Access": 0, + "Cooldown": 5, + "Min": 25, + "Max": 75, + "Multiplier": 1.7, + "Open": True, + }, + "Double": { + "Access": 0, + "Cooldown": 5, + "Min": 10, + "Max": 250, + "Multiplier": None, + "Open": True, + }, + "War": {"Access": 0, "Cooldown": 5, "Min": 25, "Max": 75, "Multiplier": 1.5, "Open": True}, + }, +} + +member_defaults = deepcopy(user_defaults) +global_defaults = deepcopy(guild_defaults) +global_defaults["Settings"]["Global"] = True + + +_DataNamedTuple = namedtuple("Casino", "foo") +_DataObj = _DataNamedTuple(foo=None) + + +log = logging.getLogger("red.jumper-plugins.casino") + + +class Database: + + config: Config = Config.get_conf(_DataObj, 5074395001, force_registration=True) + + def __init__(self): + self.config.register_guild(**guild_defaults) + self.config.register_global(schema_version=1, **global_defaults) + self.config.register_member(**member_defaults) + self.config.register_user(**user_defaults) + self.old_message_cache = OldMessageTypeManager(config=self.config, enable_cache=True) + self.migration_task: asyncio.Task = None + self.cog_ready_event: asyncio.Event = asyncio.Event() + + async def data_schema_migration(self, from_version: int, to_version: int): + if from_version == to_version: + self.cog_ready_event.set() + return + if from_version < 2 <= to_version: + try: + async with self.config.all() as casino_data: + temp = deepcopy(casino_data) + global_payout = casino_data["Settings"]["Payout_Limit"] + if is_input_unsupported(global_payout): + casino_data["Settings"]["Payout_Limit"] = await bank.get_max_balance() + for g, g_data in temp["Games"].items(): + for g_data_key, g_data_value in g_data.items(): + if g_data_key in ["Access", "Cooldown", "Max", "Min", "Multiplier"]: + if is_input_unsupported(g_data_value): + if g_data_value < min_int: + g_data_value_new = min_int + else: + g_data_value_new = max_int + casino_data["Games"][g][g_data_key] = g_data_value_new + async with self.config._get_base_group(self.config.GUILD).all() as casino_data: + temp = deepcopy(casino_data) + for guild_id, guild_data in temp.items(): + if ( + "Settings" in temp[guild_id] + and "Payout_Limit" in temp[guild_id]["Settings"] + ): + guild_payout = casino_data[guild_id]["Settings"]["Payout_Limit"] + if is_input_unsupported(guild_payout): + casino_data[guild_id]["Settings"][ + "Payout_Limit" + ] = await bank.get_max_balance( + guild_payout, guild=discord.Object(id=int(guild_id)) + ) + if "Games" in temp[guild_id]: + for g, g_data in temp[guild_id]["Games"].items(): + for g_data_key, g_data_value in g_data.items(): + if g_data_key in [ + "Access", + "Cooldown", + "Max", + "Min", + "Multiplier", + ]: + if is_input_unsupported(g_data_value): + if g_data_value < min_int: + g_data_value_new = min_int + else: + g_data_value_new = max_int + casino_data[guild_id]["Games"][g][ + g_data_key + ] = g_data_value_new + await self.config.schema_version.set(2) + except Exception as e: + log.exception( + "Fatal Exception during Data migration to Scheme 2, Casino cog will not be loaded.", + exc_info=e, + ) + raise + self.cog_ready_event.set() + + async def casino_is_global(self): + """Checks to see if the casino is storing data on + a per server basis or globally.""" + return await self.config.Settings.Global() + + async def get_data(self, ctx, player=None): + """ + + :param ctx: Context object + :param player: Member or user object + :return: Database that corresponds to the given data. + + Returns the appropriate config category based on the given + data, and wheater or not the casino is global. + """ + if await self.casino_is_global(): + if player is None: + return self.config + else: + return self.config.user(player) + else: + if player is None: + return self.config.guild(ctx.guild) + else: + return self.config.member(player) + + async def get_all(self, ctx, player): + """ + + :param ctx: Context Object + :param player: Member or user object + :return: Tuple with two dictionaries + + Returns a dictionary representation of casino's settings data + and the player data. + """ + settings = await self.get_data(ctx) + player_data = await self.get_data(ctx, player=player) + return await settings.all(), await player_data.all() + + async def _wipe_casino(self, ctx): + """ + Wipes all the casino data available + + :param ctx: context object + :return: None + + This wipes everything, including member/user data. + """ + await self.config.clear_all() + msg = "{0.name} ({0.id}) wiped all casino data.".format(ctx.author) + await ctx.send(msg) + + async def _reset_settings(self, ctx): + """ + Resets only the settings data. + """ + data = await self.get_data(ctx) + await data.Settings.clear() + msg = ("{0.name} ({0.id}) reset all casino settings.").format(ctx.author) + await ctx.send(msg) + + async def _reset_memberships(self, ctx): + """ + Resets all the information pertaining to memberships + """ + data = await self.get_data(ctx) + await data.Memberships.clear() + msg = ("{0.name} ({0.id}) cleared all casino memberships.").format(ctx.author) + await ctx.send(msg) + + async def _reset_games(self, ctx): + """ + Resets all game settings, such as multipliers and bets. + """ + data = await self.get_data(ctx) + await data.Games.clear() + msg = ("{0.name} ({0.id}) restored casino games to default settings.").format(ctx.author) + await ctx.send(msg) + + async def _reset_all_settings(self, ctx): + """ + Resets all settings, but retains all player data. + """ + await self._reset_settings(ctx) + await self._reset_memberships(ctx) + await self._reset_games(ctx) + await self._reset_cooldowns(ctx) + + async def _reset_player_stats(self, ctx, player): + """ + :param ctx: Context object + :param player: user or member object + :return: None + + Resets a player's win / played stats. + + """ + data = await self.get_data(ctx, player=player) + await data.Played.clear() + await data.Won.clear() + + msg = ("{0.name} ({0.id}) reset all stats for {1.name} ({1.id}).").format(ctx.author, player) + await ctx.send(msg) + + async def _reset_player_all(self, ctx, player): + """ + + :param ctx: context object + :param player: user or member object + :return: None + + Resets all data belonging to the user, including stats and memberships. + """ + data = await self.get_data(ctx, player=player) + await data.clear() + + msg = ("{0.name} ({0.id}) reset all data for {1.name} ({1.id}).").format(ctx.author, player) + await ctx.send(msg) + + async def _reset_player_cooldowns(self, ctx, player): + """ + + :param ctx: context object + :param player: user or member object + :return: None + + Resets all game cooldowns for a player. + """ + data = await self.get_data(ctx, player=player) + await data.Cooldowns.clear() + + msg = ("{0.name} ({0.id}) reset all cooldowns for {1.name} ({1.id}).").format(ctx.author, player) + await ctx.send(msg) + + async def _reset_cooldowns(self, ctx): + """ + Resets all game cooldowns for every player in the database. + """ + if await self.casino_is_global(): + for player in await self.config.all_users(): + user = discord.Object(id=player) + await self.config.user(user).Cooldowns.clear() + msg = ("{0.name} ({0.id}) reset all global cooldowns.").format(ctx.author) + else: + for player in await self.config.all_members(ctx.guild): + user = discord.Object(id=player) + await self.config.member(user).Cooldowns.clear() + msg = ("{0.name} ({0.id}) reset all cooldowns on {1.name}.").format(ctx.author, ctx.guild) + + await ctx.send(msg) + + async def change_mode(self, mode): + """ + + :param mode: String, must be local or global. + :return: None + + Toggles how data is stored for casino between local and global. + When switching modes, all perviously stored data will be deleted. + """ + if mode == "global": + await self.config.clear_all_members() + await self.config.clear_all_guilds() + await self.config.Settings.Global.set(True) + else: + await self.config.clear_all_users() + await self.config.clear_all_globals() + await self.config.Settings.Global.set(False) + + async def _update_cooldown(self, ctx, game, time): + player_data = await self.get_data(ctx, player=ctx.author) + await player_data.set_raw("Cooldowns", game, value=time) + + async def _get_player_membership(self, ctx, player): + """ + + :param ctx: context object + :param player: user or member object + :return: Membership name and a dictionary with the perks + + Performs a lookup on the user and the created memberhips for casino. + If the user has a memberhip that was deleted, it will return the + default basic membership. It will also set their new membership to the + default. + """ + basic = {"Reduction": 0, "Access": 0, "Color": "grey", "Bonus": 1} + player_data = await self.get_data(ctx, player=player) + name = await player_data.Membership.Name() + if name == "Basic": + return name, basic + + data = await self.get_data(ctx) + memberships = await data.Memberships.all() + try: + return name, memberships[name] + except KeyError: + await player_data.Membership.set({"Name": "Basic", "Assigned": False}) + return "Basic", basic diff --git a/casino/deck.py b/casino/deck.py new file mode 100644 index 0000000..47af1fd --- /dev/null +++ b/casino/deck.py @@ -0,0 +1,117 @@ +import random +from collections import deque +from itertools import product, chain + + +class Deck: + """Creates a Deck of playing cards.""" + + suites = (":clubs:", ":diamonds:", ":hearts:", ":spades:") + face_cards = ("King", "Queen", "Jack", "Ace") + bj_vals = {"Jack": 10, "Queen": 10, "King": 10, "Ace": 1} + war_values = {"Jack": 11, "Queen": 12, "King": 13, "Ace": 14} + + def __init__(self): + self._deck = deque() + + def __len__(self): + return len(self._deck) + + def __str__(self): + return "Standard deck of cards with {} cards remaining.".format(len(self._deck)) + + def __repr__(self): + return "Deck{!r}".format(self._deck) + + @property + def deck(self): + if len(self._deck) < 1: + self.new() + return self._deck + + def shuffle(self): + random.shuffle(self._deck) + + def war_count(self, card): + try: + return self.war_values[card[1]] + except KeyError: + return card[1] + + def bj_count(self, hand: list, hole=False): + hand = self._hand_type(hand) + if hole: + card = hand[0][1] + count = self.bj_vals[card] if isinstance(card, str) else card + return count if count > 1 else 11 + + count = sum([self.bj_vals[y] if isinstance(y, str) else y for x, y in hand]) + if any("Ace" in pair for pair in hand) and count <= 11: + count += 10 + return count + + @staticmethod + def fmt_hand(hand: list): + return ["{} {}".format(y, x) for x, y in hand] + + @staticmethod + def fmt_card(card): + return "{1} {0}".format(*card) + + @staticmethod + def hand_check(hand: list, card): + return any(x[1] == card for x in hand) + + def split(self, position: int): + self._deck.rotate(-position) + + @staticmethod + def _true_hand(hand: list): + return [x.split(" ") for x in hand] + + def draw(self, top=True): + self._check() + + if top: + card = self._deck.popleft() + else: + card = self._deck.pop() + return card + + def _check(self, num=1): + if num > 52: + raise ValueError("Can not exceed deck limit.") + if len(self._deck) < num: + self.new() + + def _hand_type(self, hand: list): + if isinstance(hand[0], tuple): + return hand + + try: + return self._true_hand(hand) + except ValueError: + raise ValueError("Invalid hand input.") + + def deal(self, num=1, top=True, hand=None): + self._check(num=num) + + if hand is None: + hand = [] + for x in range(0, num): + if top: + hand.append(self._deck.popleft()) + else: + hand.append(self._deck.pop()) + + return hand + + def burn(self, num): + self._check(num=num) + for x in range(0, num): + del self._deck[0] + + def new(self): + cards = product(self.suites, chain(range(2, 11), ("King", "Queen", "Jack", "Ace"))) + self._deck = deque(cards) + self.shuffle() diff --git a/casino/engine.py b/casino/engine.py new file mode 100644 index 0000000..dabc83b --- /dev/null +++ b/casino/engine.py @@ -0,0 +1,322 @@ +# Standard Library +import calendar +from functools import wraps + +# Casino +from typing import Optional + +from redbot.core.utils.chat_formatting import humanize_number + +from . import utils +from .data import Database + +# Red +from redbot.core import bank +from redbot.core.errors import BalanceTooHigh +from redbot.core.i18n import Translator + +# Discord +import discord + +_ = Translator("Casino", __file__) + + +def game_engine(name=None, choice=None, choices=None): + def wrapper(coro): + @wraps(coro) + async def wrapped(*args, **kwargs): + try: + user_choice = args[3] + except IndexError: + user_choice = None + engine = GameEngine(name, user_choice, choice, args[1], args[2]) + if await engine.check_conditions(): + result = await coro(*args, **kwargs) + await engine.game_teardown(result) + + return wrapped + + return wrapper + + +class GameEngine(Database): + """A class that handles setup and teardown for games. + + This is a helper class to make games easier to create games and to + provide a level of consistency. This class is only to be used + in conjunction with the game_engine decorator. + + You only need to specify the name, and depending on the game, a choice or + a list of choices to choose from. The decorater will obtain the rest of the + attributes. + + Attributes + ----------- + game: str + The name of the game. + choice: str + The decision the player chose for the game. When a decision is not + required, leave it None. + choices: list + A list of choices the player must pick from. If a list of choices is not + required, leave it None. + ctx: object + The Red context object necessary for sending/waiting for messages. + player: object + User or member object necessary for interacting with the player. + guild: object + The guild object from the Red Context object. This is used to pull data + from config. + bet: int + The amount the player has wagered. + + """ + + __slots__ = ("game", "choice", "choices", "ctx", "bet", "player", "guild") + + def __init__(self, game, choice, choices, ctx, bet): + self.game = game + self.choice = choice + self.choices = choices + self.bet = bet + self.ctx = ctx + self.player = ctx.author + self.guild = ctx.guild + super().__init__() + + async def check_conditions(self): + """ + + Performs all the necessary checks for a game. Every game must validate under these specific + checks. The following conditions are checked: + + - Checking to see if the casino is open + - Checking to see if the game is open + - Checking to see if the player has a high enough access level to play the game. + - Validating that the player's choice is in the list of declared choices. + - Checking that the bet is within the range of the set min and max. + - Checking to see that has enough currency in the bank account to cover the bet. + - Checking to see if the game is on cooldown. + + Cooldowns must be checked last so that the game doesn't trigger a cooldown if another + condition has failed. + + + """ + settings, player_data = await super().get_all(self.ctx, self.player) + access = self.access_calculator(settings["Memberships"], player_data["Membership"]["Name"]) + + if not settings["Settings"]["Casino_Open"]: + error = _("The Casino is closed.") + + elif not settings["Games"][self.game]["Open"]: + error = _("{} is closed.".format(self.game)) + + elif settings["Games"][self.game]["Access"] > access: + error = _( + "{} requires an access level of {}. Your current access level is {}. Obtain " + "a higher membership to play this game." + ).format(self.game, settings["Games"][self.game]["Access"], access) + + elif self.choices is not None and self.choice not in self.choices: + error = _("Incorrect response. Accepted responses are:\n{}.").format(utils.fmt_join(self.choices)) + + elif not self.bet_in_range( + settings["Games"][self.game]["Min"], settings["Games"][self.game]["Max"] + ): + error = _( + "Your bet must be between " + "{} and {}.".format( + settings["Games"][self.game]["Min"], settings["Games"][self.game]["Max"] + ) + ) + + elif not await bank.can_spend(self.player, self.bet): + error = _("You do not have enough credits to cover the bet.") + + else: + error = await self.check_cooldown(settings["Games"][self.game], player_data) + + if error: + await self.ctx.send(error) + return False + else: + await bank.withdraw_credits(self.player, self.bet) + await self.update_stats(stat="Played") + return True + + async def update_stats(self, stat: str): + """ + + :param stat: string + Must be Played or Won + :return: None + + Updates either a player's win or played stat. + """ + instance = await self.get_data(self.ctx, player=self.player) + current = await instance.get_raw(stat, self.game) + await instance.set_raw(stat, self.game, value=current + 1) + + async def check_cooldown(self, game_data, player_data): + """ + + :param game_data: Dictionary + Contains all the data pertaining to a particular game. + :param player_data: Object + User or member Object + :return: String or None + Returns a string when a cooldown is remaining on a game, otherwise it will + return None + + Checks the time a player last played a game, and compares it with the set cooldown + for that game. If a user is still on cooldown, then a string detailing the time + remaining will be returned. Otherwise this will update their cooldown, and return None. + + """ + user_time = player_data["Cooldowns"][self.game] + now = calendar.timegm(self.ctx.message.created_at.utctimetuple()) + base = game_data["Cooldown"] + membership = await super()._get_player_membership(self.ctx, self.player) + reduction = membership[1]["Reduction"] + if now >= user_time - reduction: + await super()._update_cooldown(self.ctx, self.game, now + base) + else: + seconds = int((user_time + reduction - now)) + remaining = utils.time_formatter(seconds) + msg = _("{} is still on a cooldown. You still have: {} remaining.").format(self.game, remaining) + return msg + + async def game_teardown(self, result): + data = await super().get_data(self.ctx) + settings = await data.all() + message_obj: Optional[discord.Message] + + win, amount, msg, message_obj = result + + if not win: + embed = await self.build_embed(msg, settings, win, total=amount, bonus="(+0)") + if (not await self.old_message_cache.get_guild(self.ctx.guild)) and message_obj: + return await message_obj.edit(content=self.player.mention, embed=embed, view=None) + else: + return await self.ctx.send(self.player.mention, embed=embed, view=None) + + player_data = await super().get_data(self.ctx, player=self.player) + await self.update_stats(stat="Won") + if self.limit_check(settings, amount): + embed = await self.build_embed(msg, settings, win, total=amount, bonus="(+0)") + return await self.limit_handler( + embed, + amount, + player_data, + settings["Settings"]["Payout_Limit"], + message=message_obj, + ) + + total, bonus = await self.deposit_winnings(amount, player_data, settings) + embed = await self.build_embed(msg, settings, win, total=total, bonus=bonus) + if (not await self.old_message_cache.get_guild(self.ctx.guild)) and message_obj: + return await message_obj.edit(content=self.player.mention, embed=embed, view=None) + else: + return await self.ctx.send(self.player.mention, embed=embed, view=None) + + async def limit_handler(self, embed, amount, player_instance, limit, message): + await player_instance.Pending_Credits.set(int(amount)) + + if (not await self.old_message_cache.get_guild(self.ctx.guild)) and message: + await message.edit(content=self.player.mention, embed=embed) + else: + await self.ctx.send(self.player.mention, embed=embed) + msg = _( + "{} Your winnings exceeded the maximum credit limit allowed ({}). The amount " + "of {} credits will be pending on your account until reviewed. Until an " + "Administrator or higher authority has released the pending currency, " + "**DO NOT** attempt to place a bet that will exceed the payout limit. You " + "may only have **ONE** pending payout at a " + "time." + ).format(self.player.name, limit, amount) + + await self.player.send(msg) + + async def deposit_winnings(self, amount, player_instance, settings): + multiplier = settings["Games"][self.game]["Multiplier"] + if self.game == "Allin" or self.game == "Double": + try: + await bank.deposit_credits(self.player, amount) + return amount, "(+0)" + except BalanceTooHigh as e: + return await bank.set_balance(self.player, e.max_balance), "(+0)" + + initial = round(amount * multiplier) + total, amt, msg = await self.calculate_bonus(initial, player_instance, settings) + try: + await bank.deposit_credits(self.player, total) + except BalanceTooHigh as e: + await bank.set_balance(self.player, e.max_balance) + return total, msg + + def bet_in_range(self, minimum, maximum): + if self.game == "Allin": + return True + + if minimum <= self.bet <= maximum: + return True + else: + return False + + async def build_embed(self, msg, settings, win, total, bonus): + balance = await bank.get_balance(self.player) + currency = await bank.get_currency_name(self.guild) + bal_msg = _("**Remaining Balance:** {} {}").format(humanize_number(balance), currency) + embed = discord.Embed() + embed.title = _("{} Casino | {}").format(settings["Settings"]["Casino_Name"], self.game) + + if isinstance(msg, discord.Embed): + for field in msg.fields: + embed.add_field(**field.__dict__) + else: + embed.description = msg + + if win: + embed.colour = 0x00FF00 + end = _("Congratulations, you just won {} {} {}!\n{}").format( + humanize_number(total), currency, bonus, bal_msg + ) + else: + embed.colour = 0xFF0000 + end = _("Sorry, you didn't win anything.\n{}").format(bal_msg) + embed.add_field(name="-" * 65, value=end) + return embed + + @staticmethod + def access_calculator(memberships, user_membership): + if user_membership == "Basic": + return 0 + + try: + access = memberships[user_membership]["Access"] + except KeyError: + return 0 + else: + return access + + @staticmethod + async def calculate_bonus(amount, player_instance, settings): + membership = await player_instance.Membership.Name() + try: + bonus_multiplier = settings["Memberships"][membership]["Bonus"] + except KeyError: + bonus_multiplier = 1 + total = round(amount * bonus_multiplier) + bonus = total - amount + return total, amount, "(+{})".format(humanize_number(bonus) if bonus_multiplier > 1 else 0) + + @staticmethod + def limit_check(settings, amount): + if settings["Settings"]["Payout_Switch"]: + if amount > settings["Settings"]["Payout_Limit"]: + return True + else: + return False + else: + return False diff --git a/casino/games.py b/casino/games.py new file mode 100644 index 0000000..0f1ede5 --- /dev/null +++ b/casino/games.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +# Standard Library +import asyncio +import random + +# Casino +from .deck import Deck +from .engine import game_engine + +# Red +from redbot.core import bank +from redbot.core.i18n import Translator +from redbot.core.errors import BalanceTooHigh +from redbot.core.utils.chat_formatting import box +from redbot.core.utils.predicates import MessagePredicate + +# Discord +import discord + + +_ = Translator("Casino", __file__) +deck = Deck() + +# Any game created must return a tuple of 3 arguments. +# The outcome (True or False) +# The final bet or amount (int) +# A msg that is either a string or an embed +# If the msg is a string it is added to the description of the final embed. +# If the msg is an embed, it's fields are added to the final embed. + + +class Core: + """ + A simple class to hold the basic original Casino mini games. + + Games + ----------- + Allin + Bet all your credits. All or nothing gamble. + Coin + Coin flip game. Pick heads or tails. + Cups + Three cups are shuffled. Pick the one covering the ball. + Dice + Roll a pair of die. 2, 7, 11, or 12 wins. + Hilo + Guess if the dice result will be high, low, or 7. + Craps + Win with a comeout roll of 7 or 11, lose on 2, 3, or 12. + If you roll any other number you must match it on your + second roll to win. + """ + + def __init__(self, old_message_cache): + self.old_message_cache = old_message_cache + + @game_engine("Allin") + async def play_allin(self, ctx, bet, multiplier): + message = await ctx.send( + _("You put all your chips into the machine and pull the lever...") + ) + await asyncio.sleep(3) + outcome = random.randint(0, multiplier + 1) + if outcome == 0: + msg = "▂▃▅▇█▓▒░ [♠] [♥] [♦] [♣] ░▒▓█▇▅▃▂\n" + msg += _(" CONGRATULATIONS YOU WON\n") + msg += _("░▒▓█▇▅▃▂ ⚅ J A C K P O T ⚅ ▂▃▅▇█▓▒░") + msg = box(msg, lang="py") + bet *= multiplier + else: + msg = _("Nothing happens. You stare at the machine contemplating your decision.") + return outcome == 0, bet, msg, message + + @game_engine("Coin", (_("heads"), _("tails"))) + async def play_coin(self, ctx, bet, choice): + message = await ctx.send(_("The coin flips into the air...")) + await asyncio.sleep(2) + outcome = random.choice((_("heads"), _("tails"))) + msg = _("The coin landed on {}!").format(outcome) + return choice.lower() in outcome, bet, msg, message + + @game_engine("Cups", ("1", "2", "3")) + async def play_cups(self, ctx, bet, choice): + message = await ctx.send(_("The cups start shuffling along the table...")) + await asyncio.sleep(3) + outcome = random.randint(1, 3) + msg = _("The coin was under cup {}!").format(outcome) + return int(choice) == outcome, bet, msg, message + + @game_engine("Dice") + async def play_dice(self, ctx, bet): + message = await ctx.send( + _("The dice strike the back of the table and begin to tumble into place...") + ) + await asyncio.sleep(2) + die_one, die_two = self.roll_dice() + outcome = die_one + die_two + + msg = _("The dice landed on {} and {} ({}).").format(die_one, die_two, outcome) + return outcome in (2, 7, 11, 12), bet, msg, message + + @game_engine("Hilo", (_("low"), _("lo"), _("high"), _("hi"), _("seven"), _("7"))) + async def play_hilo(self, ctx, bet, choice): + message = await ctx.send(_("The dice hit the table and slowly fall into place...")) + await asyncio.sleep(2) + + result = sum(self.roll_dice()) + if result < 7: + outcome = (_("low"), _("lo")) + elif result > 7: + outcome = (_("high"), _("hi")) + else: + outcome = (_("seven"), "7") + + msg = _("The outcome was {} ({[0]})!").format(result, outcome) + + if result == 7 and outcome[1] == "7": + bet *= 5 + + return choice.lower() in outcome, bet, msg, message + + @game_engine(name="Craps") + async def play_craps(self, ctx, bet): + return await self._craps_game(ctx, bet) + + async def _craps_game(self, ctx, bet, comeout=False, message=None): + msg1 = _("The dice strike against the back of the table...") + if (not await self.old_message_cache.get_guild(ctx.guild)) and message: + await asyncio.sleep(3) + await message.edit(content=msg1) + else: + message = await ctx.send(msg1) + await asyncio.sleep(2) + d1, d2 = self.roll_dice() + result = d1 + d2 + msg = _("You rolled a {} and {}.") + + if comeout: + if result == comeout: + return True, bet, msg.format(d1, d2), message + return False, bet, msg.format(d1, d2), message + + if result == 7: + bet *= 3 + return True, bet, msg.format(d1, d2), message + elif result == 11: + return True, bet, msg.format(d1, d2), message + elif result in (2, 3, 12): + return False, bet, msg.format(d1, d2), message + msg2 = _( + "{}\nI'll roll the dice one more time. This time you will need exactly {} to win." + ).format(msg.format(d1, d2), d1 + d2) + if not await self.old_message_cache.get_guild(ctx.guild): + await message.edit(content=msg2) + else: + await ctx.send(msg2) + return await self._craps_game(ctx, bet, comeout=result, message=message) + + @staticmethod + def roll_dice(): + return random.randint(1, 6), random.randint(1, 6) + + +class BlackjackView(discord.ui.View): + def __init__(self, ctx, include_double: bool): + self.ctx = ctx + super().__init__() + self.result = None + if include_double is False: + self.double_button.disabled = True + + @discord.ui.button(label="Hit", emoji="\N{RAISED FIST}") + async def hit_button(self, interaction: discord.Interaction, button): + self.result = "hit" + await interaction.response.defer() + self.stop() + + @discord.ui.button(label="Stay", emoji="\N{RAISED HAND}") + async def stay_button(self, interaction: discord.Interaction, button): + self.result = "stay" + await interaction.response.defer() + self.stop() + + @discord.ui.button(label="Double", emoji="\N{VICTORY HAND}\N{VARIATION SELECTOR-16}") + async def double_button(self, interaction: discord.Interaction, button): + self.result = "double" + await interaction.response.defer() + self.stop() + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.ctx.author.id: + await interaction.response.send_message( + "You are not authorized to interact with this.", ephemeral=True + ) + return False + return True + + +class Blackjack: + """A simple class to hold the game logic for Blackjack. + + Blackjack requires inheritance from data to verify the user + can double down. + """ + + def __init__(self, old_message_cache): + self.old_message_cache = old_message_cache + super().__init__() + + @game_engine(name="Blackjack") + async def play(self, ctx, bet): + ph, dh, amt, msg = await self.blackjack_game(ctx, bet) + result = await self.blackjack_results(ctx, amt, ph, dh, message=msg) + return result + + @game_engine(name="Blackjack") + async def mock(self, ctx, bet, ph, dh): + result = await self.blackjack_results(ctx, bet, ph, dh) + return result + + async def blackjack_game(self, ctx, amount): + ph = deck.deal(num=2) + ph_count = deck.bj_count(ph) + dh = deck.deal(num=2) + + # End game if player has 21 + if ph_count == 21: + return ph, dh, amount, None + embed = self.bj_embed(ctx, ph, dh, ph_count, initial=True) + view = BlackjackView(ctx, include_double=True) + msg = await ctx.send(ctx.author.mention, embed=embed, view=view) + await view.wait() + + if view.result == "stay": + dh = self.dealer(dh) + return ph, dh, amount, msg + + if view.result == "double": + return await self.double_down(ctx, ph, dh, amount, message=msg) + else: + ph, dh, message = await self.bj_loop(ctx, ph, dh, ph_count, message=msg) + dh = self.dealer(dh) + return ph, dh, amount, msg + + async def double_down(self, ctx, ph, dh, amount, message): + try: + await bank.withdraw_credits(ctx.author, amount) + except ValueError: + await ctx.send( + _("{} You can not cover the bet. Please choose hit or stay.").format( + ctx.author.mention + ) + ) + view = BlackjackView(ctx, include_double=False) + ph_count = deck.bj_count(ph) + embed = self.bj_embed(ctx, ph, dh, ph_count, initial=False) + if not await self.old_message_cache.get_guild(ctx.guild): + await message.edit(content=ctx.author.mention, embed=embed, view=view) + else: + await ctx.send(content=ctx.author.mention, embed=embed, view=view) + await view.wait() + + if view.result == "stay": + dh = self.dealer(dh) + return ph, dh, amount, message + elif view.result == "hit": + ph, dh, message = await self.bj_loop( + ctx, ph, dh, deck.bj_count(ph), message=message + ) + dh = self.dealer(dh) + return ph, dh, amount, message + else: + deck.deal(hand=ph) + dh = self.dealer(dh) + amount *= 2 + return ph, dh, amount, message + + async def blackjack_results(self, ctx, amount, ph, dh, message=None): + dc = deck.bj_count(dh) + pc = deck.bj_count(ph) + + if dc > 21 >= pc or dc < pc <= 21: + outcome = _("Winner!") + result = True + elif pc > 21: + outcome = _("BUST!") + result = False + elif dc == pc <= 21: + outcome = _("Pushed") + try: + await bank.deposit_credits(ctx.author, amount) + except BalanceTooHigh as e: + await bank.set_balance(ctx.author, e.max_balance) + result = False + else: + outcome = _("House Wins!") + result = False + embed = self.bj_embed(ctx, ph, dh, pc, outcome=outcome) + return result, amount, embed, message + + async def bj_loop(self, ctx, ph, dh, count, message: discord.Message): + while count < 21: + ph = deck.deal(hand=ph) + count = deck.bj_count(hand=ph) + + if count >= 21: + break + embed = self.bj_embed(ctx, ph, dh, count) + view = BlackjackView(ctx, include_double=False) + if not await self.old_message_cache.get_guild(ctx.guild): + await message.edit(content=ctx.author.mention, embed=embed, view=view) + else: + await ctx.send(content=ctx.author.mention, embed=embed, view=view) + await view.wait() + if view.result == "stay": + break + await asyncio.sleep(1) + + # Return player hand & dealer hand when count >= 21 or the player picks stay. + return ph, dh, message + + @staticmethod + def dealer(dh): + count = deck.bj_count(dh) + # forces hit if ace in first two cards without 21 + if deck.hand_check(dh, "Ace") and count != 21: + deck.deal(hand=dh) + count = deck.bj_count(dh) + + # defines maximum hit score X + while count < 17: + deck.deal(hand=dh) + count = deck.bj_count(dh) + return dh + + @staticmethod + def bj_embed(ctx, ph, dh, count1, initial=False, outcome=None): + hand = _("{}\n**Score:** {}") + footer = _("Cards in Deck: {}") + start = _("**Options:** hit, stay, or double") + after = _("**Options:** hit or stay") + options = "**Outcome:** " + outcome if outcome else start if initial else after + count2 = deck.bj_count(dh, hole=True) if not outcome else deck.bj_count(dh) + hole = " ".join(deck.fmt_hand([dh[0]])) + dealer_hand = hole if not outcome else ", ".join(deck.fmt_hand(dh)) + + embed = discord.Embed(colour=0xFF0000) + embed.add_field( + name=_("{}'s Hand").format(ctx.author.name), + value=hand.format(", ".join(deck.fmt_hand(ph)), count1), + ) + embed.add_field( + name=_("{}'s Hand").format(ctx.bot.user.name), value=hand.format(dealer_hand, count2) + ) + embed.add_field(name="\u200b", value=options, inline=False) + embed.set_footer(text=footer.format(len(deck))) + return embed + + +class WarView(discord.ui.View): + def __init__(self, ctx): + self.ctx = ctx + super().__init__() + self.result = None + + @discord.ui.button(label=_("War")) + async def war_button(self, interaction: discord.Interaction, button): + self.result = "war" + await interaction.response.defer() + self.stop() + + @discord.ui.button(label=_("Surrender"), emoji="\N{WAVING WHITE FLAG}\N{VARIATION SELECTOR-16}") + async def surrender_button(self, interaction: discord.Interaction, button): + self.result = "surrender" + await interaction.response.defer() + self.stop() + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.ctx.author.id: + await interaction.response.send_message( + "You are not authorized to interact with this.", ephemeral=True + ) + return False + return True + + +class War: + """A simple class for the war card game.""" + + def __init__(self, old_message_cache): + self.old_message_cache = old_message_cache + + @game_engine("War") + async def play(self, ctx, bet): + outcome, player_card, dealer_card, amount, msg = await self.war_game(ctx, bet) + return await self.war_results(outcome, player_card, dealer_card, amount, message=msg) + + async def war_game(self, ctx, bet): + player_card, dealer_card, pc, dc = self.war_draw() + + message = await ctx.send( + _( + "The dealer shuffles the deck and deals 2 cards face down. One for the " + "player and one for the dealer..." + ) + ) + await asyncio.sleep(2) + text = _("**FLIP!**") + if not await self.old_message_cache.get_guild(ctx.guild): + await message.edit(content=text) + else: + await ctx.send(text) + await asyncio.sleep(1) + + if pc != dc: + if pc >= dc: + outcome = "Win" + else: + outcome = "Loss" + return outcome, player_card, dealer_card, bet, message + content = _( + "The player and dealer are both showing a **{}**!\nTHIS MEANS " + "WAR! You may choose to surrender and forfeit half your bet, or " + "you can go to war.\nIf you go to war your bet will be doubled, " + "but the multiplier is only applied to your original bet, the rest will " + "be pushed." + ).format(deck.fmt_card(player_card)) + view = WarView(ctx) + if not await self.old_message_cache.get_guild(ctx.guild): + await message.edit(content=content, view=view) + else: + await ctx.send(content=content, view=view) + await view.wait() + choice = view.result + + if choice is None or choice.title() in (_("Surrender"), _("Ffs")): + outcome = "Surrender" + bet /= 2 + return outcome, player_card, dealer_card, bet, message + else: + player_card, dealer_card, pc, dc = self.burn_and_draw() + + msg1 = _("The dealer burns three cards and deals two cards face down...") + msg2 = _("**FLIP!**") + + if not await self.old_message_cache.get_guild(ctx.guild): + action = message.edit + else: + action = ctx.send + + await action(content=msg1) + await asyncio.sleep(3) + await action(content=msg2) + + if pc >= dc: + outcome = "Win" + else: + outcome = "Loss" + return outcome, player_card, dealer_card, bet, message + + @staticmethod + async def war_results(outcome, player_card, dealer_card, amount, message=None): + msg = _("**Player Card:** {}\n**Dealer Card:** {}\n").format( + deck.fmt_card(player_card), deck.fmt_card(dealer_card) + ) + if outcome == "Win": + msg += _("**Result**: Winner") + return True, amount, msg, message + + elif outcome == "Loss": + msg += _("**Result**: Loser") + return False, amount, msg, message + else: + msg += _("**Result**: Surrendered") + return False, amount, msg, message + + @staticmethod + def get_count(pc, dc): + return deck.war_count(pc), deck.war_count(dc) + + def war_draw(self): + player_card, dealer_card = deck.deal(num=2) + pc, dc = self.get_count(player_card, dealer_card) + return player_card, dealer_card, pc, dc + + def burn_and_draw(self): + deck.burn(3) + player_card, dealer_card = deck.deal(num=2) + pc, dc = self.get_count(player_card, dealer_card) + return player_card, dealer_card, pc, dc + + +class DoubleView(discord.ui.View): + def __init__(self, ctx): + self.ctx = ctx + super().__init__() + self.result = None + + @discord.ui.button(label=_("Double")) + async def war_button(self, interaction: discord.Interaction, button): + self.result = "double" + await interaction.response.defer() + self.stop() + + @discord.ui.button(label=_("Cash out"), emoji="\N{BANKNOTE WITH DOLLAR SIGN}") + async def surrender_button(self, interaction: discord.Interaction, button): + self.result = None + # set this to None so we exit even if the user doesn't interact + await interaction.response.defer() + self.stop() + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.ctx.author.id: + await interaction.response.send_message( + "You are not authorized to interact with this.", ephemeral=True + ) + return False + return True + + +class Double: + """A simple class for the Double Or Nothing game.""" + + def __init__(self, old_message_cache): + self.old_message_cache = old_message_cache + + @game_engine("Double") + async def play(self, ctx, bet): + count, amount, message = await self.double_game(ctx, bet) + return await self.double_results(ctx, count, amount, message=message) + + async def double_game(self, ctx, bet): + count = 0 + message = None + while bet > 0: + count += 1 + + flip = random.randint(0, 1) + + if flip == 0: + bet = 0 + break + + else: + bet *= 2 + view = DoubleView(ctx) + + embed = self.double_embed(ctx, count, bet) + if (not await self.old_message_cache.get_guild(ctx.guild)) and message: + await message.edit(content=ctx.author.mention, embed=embed, view=view) + else: + message = await ctx.send(ctx.author.mention, embed=embed, view=view) + await view.wait() + + if not view.result: + break + await asyncio.sleep(1) + + return count, bet, message + + async def double_results(self, ctx, count, amount, message=None): + if amount > 0: + outcome = _("Cashed Out!") + result = True + else: + outcome = _("You Lost It All!") + result = False + embed = self.double_embed(ctx, count, amount, outcome=outcome) + return result, amount, embed, message + + @staticmethod + def double_embed(ctx, count, amount, outcome=None): + double = _("{}\n**DOUBLE!:** x{}") + zero = _("{}\n**NOTHING!**") + choice = _("**Options:** double or cash out") + options = "**Outcome:** " + outcome if outcome else choice + + if amount == 0: + score = zero.format(amount) + else: + score = double.format(amount, count) + + embed = discord.Embed(colour=0xFF0000) + embed.add_field(name=_("{}'s Score").format(ctx.author.name), value=score) + embed.add_field(name="\u200b", value=options, inline=False) + if not outcome: + embed.add_field( + name="\u200b", value="Remember, you can cash out at anytime.", inline=False + ) + embed.set_footer(text="Try again and test your luck!") + return embed diff --git a/casino/info.json b/casino/info.json new file mode 100644 index 0000000..4d0eff3 --- /dev/null +++ b/casino/info.json @@ -0,0 +1,13 @@ +{ + "author" : ["Redjumpman (Redjumpman#1337)"], + "install_msg" : "Thank you for installing casino. Be sure to check out the wiki here: https://github.com/Redjumpman/Jumper-Plugins/wiki/Casino-RedV3\nThis cog may put a heavy load on your bot if used with 10k users or more.", + "name" : "Casino", + "short" : "Casino style mini games.", + "requirements" : ["tabulate"], + "description" : "Play up to 7 unique games and earn currency.", + "permissions" : ["Manage Messages", "Embed Links"], + "tags" : ["Games", "Economy", "Fun", "Casino"], + "min_python_version": [3, 8, 1], + "min_bot_version": "3.2.0", + "end_user_data_statement": "This cog stores discord IDs as needed for operation." +} diff --git a/casino/utils.py b/casino/utils.py new file mode 100644 index 0000000..a003ceb --- /dev/null +++ b/casino/utils.py @@ -0,0 +1,128 @@ +import re +import math + +from typing import Union, Dict, List, Sequence + +utf8_re = re.compile(r"^[\U00000000-\U0010FFFF]*$") +min_int, max_int = 1 - (2 ** 64), (2 ** 64) - 1 + + +def is_input_unsupported(data: Union[Dict, List, str, int, float]): + if type(data) is dict: + for k, v in data.items(): + if is_input_unsupported(k) or is_input_unsupported(v): + return True + if type(data) is list: + for i in data: + if is_input_unsupported(i): + return True + if type(data) is str and not utf8_re.match(data): + return True + if type(data) is int: + if not (min_int <= data <= max_int): + return True + if type(data) is float: + if math.isnan(data) or math.isinf(data): + return True + if not (min_int <= data <= max_int): + return True + + +class PluralDict(dict): + """This class is used to plural strings + + You can plural strings based on the value input when using this class as a dictionary. + """ + + def __missing__(self, key): + if "(" in key and key.endswith(")"): + key, rest = key.split("(", 1) + value = super().__getitem__(key) + suffix = rest.rstrip(")").split(",") + if len(suffix) == 1: + suffix.insert(0, "") + return suffix[0] if value <= 1 else suffix[1] + raise KeyError(key) + + +def time_converter(units): + return sum(int(x) * 60 ** i for i, x in enumerate(reversed(units.split(":")))) + + +def color_lookup(color="grey"): + colors = { + "blue": 0x3366FF, + "red": 0xFF0000, + "green": 0x00CC33, + "orange": 0xFF6600, + "purple": 0xA220BD, + "yellow": 0xFFFF00, + "teal": 0x009999, + "magenta": 0xBA2586, + "turquoise": 0x00FFFF, + "grey": 0x666666, + "pink": 0xFE01D1, + "white": 0xFFFFFF, + } + color = colors[color] + return color + + +def fmt_join(words: Sequence, ending: str = "or"): + if not words: + return "" + elif len(words) == 1: + return words[0] + else: + return "{} {} {}".format(", ".join(map(str, words[:-1])), ending, words[-1]) + + +def cooldown_formatter(seconds, custom_msg="0"): + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + + if h > 0: + msg = "{0}h" + if m > 0 and s > 0: + msg += ", {1}m, and {2}s" + elif s > 0 and m == 0: + msg += " and {2}s" + elif s == 0 and m == 0: + pass + else: + msg += " and {1}m" + elif h == 0 and m > 0: + msg = "{1}m" if s == 0 else "{1}m and {2}s" + elif m == 0 and h == 0 and s > 0: + msg = "{2}s" + else: + msg = custom_msg + return msg.format(h, m, s) + + +def time_formatter(seconds): + # Calculate the time and input into a dict to plural the strings later. + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + data = PluralDict({"hour": h, "minute": m, "second": s}) + + # Determine the remaining time. + if h > 0: + fmt = "{hour} hour{hour(s)}" + if data["minute"] > 0 and data["second"] > 0: + fmt += ", {minute} minute{minute(s)}, and {second} second{second(s)}" + if data["second"] > 0 == data["minute"]: + fmt += ", and {second} second{second(s)}" + msg = fmt.format_map(data) + elif h == 0 and m > 0: + if data["second"] == 0: + fmt = "{minute} minute{minute(s)}" + else: + fmt = "{minute} minute{minute(s)}, and {second} second{second(s)}" + msg = fmt.format_map(data) + elif m == 0 and h == 0 and s > 0: + fmt = "{second} second{second(s)}" + msg = fmt.format_map(data) + else: + msg = "None" + return msg