diff --git a/src/bot.py b/src/bot.py index be7833c..27fdd93 100644 --- a/src/bot.py +++ b/src/bot.py @@ -28,6 +28,9 @@ async def main(): await bot.load_extension("src.cogs.coinflip") await bot.load_extension("src.cogs.towers") await bot.load_extension("src.cogs.baccarat") + await bot.load_extension("src.cogs.hilo") + await bot.load_extension("src.cogs.mines") + await bot.load_extension("src.cogs.packs") await bot.start(TOKEN) if __name__ == "__main__": diff --git a/src/cogs/blackjack.py b/src/cogs/blackjack.py index 843e5b8..ae925f6 100644 --- a/src/cogs/blackjack.py +++ b/src/cogs/blackjack.py @@ -265,7 +265,7 @@ class BlackjackPanel(discord.ui.View): e = discord.Embed(title="๐ŸŽฒ Blackjack", color=discord.Color.blurple()) e.description = ( "Set your **bet**, then press **Deal** to start.\n" - "During a hand use **Hit / Stand / Double / Split**.\n" + "During a hand use **Hit / Stand / Double / Split**.\n\n" f"**Current bet:** ${self.bet}\n" ) e.add_field(name="Balance", value=f"${cash:,}", inline=True) diff --git a/src/cogs/economy.py b/src/cogs/economy.py index 4f1d77b..f83c8d7 100644 --- a/src/cogs/economy.py +++ b/src/cogs/economy.py @@ -1,15 +1,36 @@ import discord from discord.ext import commands from .. import db -from ..utils.constants import ( - DAILY_CASH, DAILY_FREE_SPINS, - PAYOUTS, SPECIAL_SYMBOL, SPECIAL_BONUS_SPINS, - SLOTS_MIN_BET, SLOTS_MAX_BET, WILDCARD_FACTOR, - TOPUP_AMOUNT, TOPUP_COOLDOWN_MINUTES, DAILY_COOLDOWN_HOURS, - COINFLIP_MIN_CHIP, COINFLIP_MIN_BET, COINFLIP_MAX_BET, TOWERS_EDGE_PER_STEP, -) -# ---------------- Rules UI (unchanged) ---------------- +# Try to import constants; provide safe defaults if missing. +try: + from ..utils.constants import ( + DAILY_CASH, DAILY_FREE_SPINS, + PAYOUTS, SPECIAL_SYMBOL, SPECIAL_BONUS_SPINS, + SLOTS_MIN_BET, SLOTS_MAX_BET, WILDCARD_FACTOR, + TOPUP_AMOUNT, TOPUP_COOLDOWN_MINUTES, DAILY_COOLDOWN_HOURS, + COINFLIP_MIN_CHIP, COINFLIP_MIN_BET, COINFLIP_MAX_BET, + TOWERS_EDGE_PER_STEP, PACK_SIZE + ) +except Exception: + DAILY_CASH = 10_000 + DAILY_FREE_SPINS = 0 + PAYOUTS = {} + SPECIAL_SYMBOL = "โญ" + SPECIAL_BONUS_SPINS = 5 + SLOTS_MIN_BET = 10 + SLOTS_MAX_BET = 5000 + WILDCARD_FACTOR = 0.8 + TOPUP_AMOUNT = 100 + TOPUP_COOLDOWN_MINUTES = 5 + DAILY_COOLDOWN_HOURS = 24 + COINFLIP_MIN_CHIP = 10 + COINFLIP_MIN_BET = 10 + COINFLIP_MAX_BET = 5000 + TOWERS_EDGE_PER_STEP = 0.015 + PACK_SIZE = 5 + +# ---------------- Rules UI ---------------- class RulesView(discord.ui.View): def __init__(self): super().__init__(timeout=60) @@ -18,12 +39,15 @@ class RulesView(discord.ui.View): class RulesSelect(discord.ui.Select): def __init__(self): options = [ - discord.SelectOption(label="Blackjack", emoji="๐Ÿƒ", description="How to play Blackjack"), - discord.SelectOption(label="Slots", emoji="๐ŸŽฐ", description="How to play Fruit Slots"), - discord.SelectOption(label="Roulette (Mini)", emoji="๐ŸŽฏ", description="How to play Mini Roulette"), - discord.SelectOption(label="Coin Flip", emoji="๐Ÿช™", description="How to play Coin Flip"), - discord.SelectOption(label="Towers", emoji="๐Ÿ‰", description="How to play Towers"), - discord.SelectOption(label="Baccarat", emoji="๐ŸŽด", description="How to play Baccarat"), + discord.SelectOption(label="Blackjack", emoji="๐Ÿƒ", description="How to play Blackjack"), + discord.SelectOption(label="Slots", emoji="๐ŸŽฐ", description="How to play Fruit Slots"), + discord.SelectOption(label="Roulette (Mini)", emoji="๐ŸŽฏ", description="How to play Mini Roulette"), + discord.SelectOption(label="Coin Flip", emoji="๐Ÿช™", description="How to play Coin Flip"), + discord.SelectOption(label="Towers", emoji="๐Ÿ‰", description="How to play Towers"), + discord.SelectOption(label="Baccarat", emoji="๐ŸŽด", description="How to play Baccarat"), + discord.SelectOption(label="Hi-Lo", emoji="๐Ÿ”ผ", description="How to play Hi-Lo"), + discord.SelectOption(label="Mines", emoji="๐Ÿ’ฃ", description="Pick safe tiles, avoid bombs"), + discord.SelectOption(label="Packs", emoji="๐Ÿ“ฆ", description="Open 5-item packs & build a collection"), ] super().__init__(placeholder="Choose a gameโ€ฆ", options=options, min_values=1, max_values=1) @@ -39,20 +63,46 @@ class RulesSelect(discord.ui.Select): embed = rules_towers_embed() elif choice == "Baccarat": embed = rules_baccarat_embed() - else: # Coin Flip - embed = rules_coin_embed() + elif choice == "Hi-Lo": + embed = rules_hilo_embed() + elif choice == "Mines": + embed = rules_mines_embed() + else: # Packs or fallback + embed = rules_packs_embed() await interaction.response.edit_message(embed=embed, view=self.view) +# ---- Rules embeds ---- + +def rules_mines_embed(): + e = discord.Embed(title="๐Ÿ’ฃ Mines โ€” Rules", color=discord.Color.dark_green()) + e.description = ( + "A 5ร—5 board hides a set number of **mines** (you choose 1, 3, 5, or 7).\n" + "Start a round (bet is debited), then pick cells. Each **safe** pick grows your cashout; " + "**Cash Out** anytime. Hitting a mine loses the bet.\n\n" + "Each safe pick multiplies by roughly `total_remaining / safe_remaining` minus a small house edge." + ) + return e + +def rules_hilo_embed(): + e = discord.Embed(title="๐Ÿ”ผ Hi-Lo โ€” Rules", color=discord.Color.dark_green()) + e.description = ( + "Guess whether the **next card** will be **Higher** or **Lower**.\n" + "โ€ข Ties **push** (no change)\n" + "โ€ข Each correct guess multiplies your cashout; **Cash Out** anytime\n" + "โ€ข Edge per step: about 2% (tunable)\n\n" + "From low cards, **Higher** pays less; from high cards, **Lower** pays less. " + "From extremes (A/K) the safe side pays ~ร—1." + ) + return e + def rules_baccarat_embed(): e = discord.Embed(title="๐ŸŽด Baccarat โ€” Rules", color=discord.Color.dark_green()) e.description = ( "**Bets:** Player 1:1 โ€ข Banker 1:1 (5% commission) โ€ข Tie 8:1 (Player/Banker push on tie)\n" - "Cards: A=1, 10/J/Q/K=0. Totals are modulo 10.\n" - "Naturals (8/9) stand; otherwise Player/Banker draw by standard third-card rules." + "Cards: A=1, 10/J/Q/K=0 (mod 10 totals). Naturals (8/9) stand; otherwise standard third-card rules apply." ) return e - def rules_towers_embed(): e = discord.Embed(title="๐Ÿ‰ Towers โ€” Rules", color=discord.Color.dark_teal()) e.description = ( @@ -62,8 +112,7 @@ def rules_towers_embed(): "โ€ข **Hard:** 2 / 1\n" "โ€ข **Expert:** 3 / 2\n" "โ€ข **Master:** 4 / 3\n\n" - "You pay your bet once on the first pick. Each safe pick multiplies your cashout; " - f"a small house edge (~{int(TOWERS_EDGE_PER_STEP*100)}% per step) is applied. " + f"You pay your bet once on the first pick. Each safe pick multiplies your cashout (edge ~{int(TOWERS_EDGE_PER_STEP*100)}% per step). " "Cash out anytime; hit a bad tile and you lose the bet." ) return e @@ -71,9 +120,9 @@ def rules_towers_embed(): def rules_coin_embed(): e = discord.Embed(title="๐Ÿช™ Coin Flip โ€” Rules", color=discord.Color.blurple()) e.description = ( - "Pick **Heads** or **Tails**, set your **Chip**, and flip.\n" - "**Payout:** 1:1 โ€ข " - f"**Limits:** min chip ${COINFLIP_MIN_CHIP}, per flip ${COINFLIP_MIN_BET}โ€“${COINFLIP_MAX_BET}" + "Pick **Heads** or **Tails**, set your **Bet**, and flip.\n" + f"**Payout:** 1:1 โ€ข **Limits:** per flip ${COINFLIP_MIN_BET}โ€“${COINFLIP_MAX_BET} " + f"(min chip ${COINFLIP_MIN_CHIP})." ) return e @@ -82,24 +131,28 @@ def rules_blackjack_embed(): e.description = ( "**Objective:** Get as close to 21 as possible without going over.\n\n" "**Card Values:** Numbers=face value, J/Q/K=10, A=11 or 1.\n\n" - "**Actions:** Hit, Stand, Double Down (first two cards), Split (matching ranks/10-values).\n" + "**Actions:** Hit, Stand, Double Down (first two cards), Split (matching ranks/10-values). " "Dealer hits to 16, stands on 17.\n\n" "**Payouts:** Win=1:1, Blackjack=3:2, Push=bet returned." ) return e def rules_slots_embed(): - pairs = sorted(PAYOUTS.items(), key=lambda kv: kv[1], reverse=True) - lines = [f"{sym} x{mult}" for sym, mult in pairs] - table = "```\nSymbol Multiplier\n" + "\n".join(lines) + "\n```" + # Payout table (if provided) to pretty-print + if PAYOUTS: + pairs = sorted(PAYOUTS.items(), key=lambda kv: kv[1], reverse=True) + lines = [f"{sym} x{mult}" for sym, mult in pairs] + table = "```\nSymbol Multiplier\n" + "\n".join(lines) + "\n```" + else: + table = "โ€”" e = discord.Embed(title="๐ŸŽฐ Fruit Slots โ€” Rules", color=discord.Color.purple()) e.description = ( "**Board:** 3ร—3 grid. Wins on rows, columns, and diagonals.\n" - f"**Bet:** `!slots ` (min ${SLOTS_MIN_BET}, max ${SLOTS_MAX_BET}).\n" - "**Paylines:** Choose **1 / 3 / 5 / 8** lines on the message. Your **spin bet** is split evenly across active lines.\n" + f"**Bet:** set on the panel (min ${SLOTS_MIN_BET}, max ${SLOTS_MAX_BET}).\n" + "**Paylines:** Choose **1 / 3 / 5 / 8** lines. Your **spin bet** is split evenly across active lines.\n" "โ€ข Example: Bet $80 on 8 lines โ†’ $10 per line; a line paying x9 returns $90.\n\n" - f"**Wildcard:** โญ counts as any fruit only for lines with **exactly two of the same fruit + one โญ**; pays **{int(WILDCARD_FACTOR*100)}%** of that fruit's multiplier.\n" + f"**Wildcard:** โญ acts as any fruit only for lines with **exactly two of the same fruit + one โญ**; pays **{int(WILDCARD_FACTOR*100)}%** of that fruit's multiplier.\n" f"**Bonus:** {SPECIAL_SYMBOL}{SPECIAL_SYMBOL}{SPECIAL_SYMBOL} on the **middle row** awards **{SPECIAL_BONUS_SPINS} free spins** (only if that line is active).\n" "**Payouts:** Three-in-a-row multipliers (applied per winning line ร— per-line bet)." ) @@ -113,11 +166,22 @@ def rules_roulette_mini_embed(): "**Outside Bets:** Red/Black 1:1 โ€ข Even/Odd 1:1 (0 loses) โ€ข Low/High 1:1 โ€ข " "Column (6 nums) 2:1 โ€ข Sixes (1โ€“6 / 7โ€“12 / 13โ€“18) 2:1\n" "**Inside Bets:** Straight 17:1 โ€ข Split 8:1 โ€ข Street 5:1 โ€ข Six-line 2:1\n\n" - "Use **Add Outside/Inside** to build the slip, set **Chip**, then **Spin** or **Spin Again**." + "Use the panel to build bets, set **Bet**, then **Spin** / **Spin Again**." ) return e -# ---------------- Casino menu ---------------- +def rules_packs_embed(): + e = discord.Embed(title="๐Ÿ“ฆ Packs โ€” Rules", color=discord.Color.dark_gold()) + e.description = ( + f"Open a pack of **{PACK_SIZE}** items. Each item has a **rarity** and a **payout multiplier**.\n" + "Your return is the **sum of multipliers ร— your bet**. New items are added to your **collection**.\n\n" + "**Rarities:** Common, Uncommon, Rare, Epic, Legendary, Mythic (rarest).\n" + "Use **Set Bet**, **ร—2**, **ยฝ**, then **Open Pack**. Use **Open Again** to repeat quickly.\n" + "Click **Collection** on the panel to see progress." + ) + return e + +# ---------------- Casino launcher ---------------- class CasinoView(discord.ui.View): """Buttons to launch any game for the invoking user.""" def __init__(self, cog: "Economy", ctx: commands.Context): @@ -133,17 +197,16 @@ class CasinoView(discord.ui.View): return True async def _launch(self, interaction: discord.Interaction, command_name: str): - # Acknowledge the click fast so Discord doesn't show "interaction failed" + # Acknowledge fast try: await interaction.response.defer(thinking=False) except Exception: pass - # Delete the casino menu message to avoid clutter + # Delete the menu to reduce clutter try: await interaction.message.delete() except Exception: - # Fallback: disable the view & mark as launching for item in self.children: item.disabled = True try: @@ -151,32 +214,40 @@ class CasinoView(discord.ui.View): except Exception: pass - # Now invoke the actual game command + # Invoke command cmd = self.cog.bot.get_command(command_name) if cmd is None: return await self.ctx.send(f"โš ๏ธ Command `{command_name}` not found.") await self.ctx.invoke(cmd) + @discord.ui.button(label="Blackjack", style=discord.ButtonStyle.success, emoji="๐Ÿƒ", row=0) + async def bj(self, itx: discord.Interaction, _): await self._launch(itx, "blackjack") - @discord.ui.button(label="Blackjack", style=discord.ButtonStyle.success, emoji="๐Ÿƒ", row=0) - async def bj(self, itx: discord.Interaction, _): await self._launch(itx, "blackjack") + @discord.ui.button(label="Slots", style=discord.ButtonStyle.primary, emoji="๐ŸŽฐ", row=0) + async def slots(self, itx: discord.Interaction, _): await self._launch(itx, "slots") - @discord.ui.button(label="Slots", style=discord.ButtonStyle.primary, emoji="๐ŸŽฐ", row=0) - async def slots(self, itx: discord.Interaction, _): await self._launch(itx, "slots") + @discord.ui.button(label="Mini Roulette", style=discord.ButtonStyle.secondary,emoji="๐ŸŽฏ", row=1) + async def roulette(self, itx: discord.Interaction, _): await self._launch(itx, "roulette") - @discord.ui.button(label="Mini Roulette", style=discord.ButtonStyle.secondary, emoji="๐ŸŽฏ", row=1) - async def roulette(self, itx: discord.Interaction, _): await self._launch(itx, "roulette") + @discord.ui.button(label="Coin Flip", style=discord.ButtonStyle.secondary,emoji="๐Ÿช™", row=1) + async def coin(self, itx: discord.Interaction, _): await self._launch(itx, "coin") - @discord.ui.button(label="Coin Flip", style=discord.ButtonStyle.secondary, emoji="๐Ÿช™", row=1) - async def coin(self, itx: discord.Interaction, _): await self._launch(itx, "coin") + @discord.ui.button(label="Towers", style=discord.ButtonStyle.secondary,emoji="๐Ÿ‰", row=2) + async def towers(self, itx: discord.Interaction, _): await self._launch(itx, "towers") - @discord.ui.button(label="Towers", style=discord.ButtonStyle.secondary, emoji="๐Ÿ‰", row=2) - async def towers(self, itx: discord.Interaction, _): await self._launch(itx, "towers") + @discord.ui.button(label="Baccarat", style=discord.ButtonStyle.secondary,emoji="๐ŸŽด", row=2) + async def baccarat(self, itx: discord.Interaction, _): await self._launch(itx, "baccarat") - @discord.ui.button(label="Baccarat", style=discord.ButtonStyle.secondary, emoji="๐ŸŽด", row=2) - async def baccarat(self, itx: discord.Interaction, _): await self._launch(itx, "baccarat") + @discord.ui.button(label="Hi-Lo", style=discord.ButtonStyle.secondary,emoji="๐Ÿ”ผ", row=2) + async def hilo(self, itx: discord.Interaction, _): await self._launch(itx, "hilo") + @discord.ui.button(label="Mines", style=discord.ButtonStyle.secondary,emoji="๐Ÿ’ฃ", row=2) + async def mines(self, itx: discord.Interaction, _): await self._launch(itx, "mines") + @discord.ui.button(label="Packs", style=discord.ButtonStyle.secondary,emoji="๐Ÿ“ฆ", row=3) + async def packs(self, itx: discord.Interaction, _): await self._launch(itx, "packs") + +# ---------------- Economy cog ---------------- class Economy(commands.Cog): def __init__(self, bot): self.bot = bot @@ -222,16 +293,21 @@ class Economy(commands.Cog): if target.avatar: embed.set_thumbnail(url=target.avatar.url) - # Topline economy - embed.add_field(name="Cash", value=f"${s['cash']}", inline=True) - embed.add_field(name="Free Spins", value=f"{s['free_spins']}", inline=True) + # Topline + embed.add_field(name="Cash", value=f"${s['cash']}", inline=True) + embed.add_field(name="Free Spins", value=f"{s['free_spins']}", inline=True) embed.add_field(name="Overall W/L", value=f"{s['games_won']}/{s['games_played']} ({pct(s['games_won'], s['games_played'])})", inline=True) + + # Per-game embed.add_field(name="๐Ÿƒ Blackjack", value=f"W/L: **{s['gw_bj']}/{s['gp_bj']}** ({pct(s['gw_bj'], s['gp_bj'])})", inline=True) embed.add_field(name="๐ŸŽฐ Slots", value=f"W/L: **{s['gw_slots']}/{s['gp_slots']}** ({pct(s['gw_slots'], s['gp_slots'])})", inline=True) embed.add_field(name="๐ŸŽฏ Roulette", value=f"Spins: **{s['gp_roulette']}** โ€ข Net: **{'+' if s['roulette_net']>=0 else ''}${s['roulette_net']}**", inline=True) embed.add_field(name="๐Ÿช™ Coin Flip", value=f"W/L: **{s['gw_coin']}/{s['gp_coin']}** ({pct(s['gw_coin'], s['gp_coin'])})", inline=True) embed.add_field(name="๐Ÿ‰ Towers", value=f"W/L: **{s['gw_towers']}/{s['gp_towers']}** ({pct(s['gw_towers'], s['gp_towers'])})", inline=True) embed.add_field(name="๐ŸŽด Baccarat", value=f"Hands: **{s['gp_bac']}** โ€ข Net: **{'+' if s['bac_net']>=0 else ''}${s['bac_net']}**", inline=True) + embed.add_field(name="๐Ÿ”ผ Hi-Lo", value=f"Rounds: **{s['gp_hilo']}** โ€ข Net: **{'+' if s['hilo_net']>=0 else ''}${s['hilo_net']}**", inline=True) + embed.add_field(name="๐Ÿ’ฃ Mines", value=f"Rounds: **{s['gp_mines']}** โ€ข Net: **{'+' if s['mines_net']>=0 else ''}${s['mines_net']}**", inline=True) + embed.add_field(name="๐Ÿ“ฆ Packs", value=f"Opened: **{s['gp_packs']}** โ€ข Net: **{'+' if s['packs_net']>=0 else ''}${s['packs_net']}**", inline=True) await ctx.send(embed=embed) @@ -241,14 +317,13 @@ class Economy(commands.Cog): can, secs_left = db.daily_cooldown(user_id, DAILY_COOLDOWN_HOURS) if not can: h = secs_left // 3600; m = (secs_left % 3600) // 60; s = secs_left % 60 - await ctx.send(f"โณ You can claim again in **{h}h {m}m {s}s**.") - return + return await ctx.send(f"โณ You can claim again in **{h}h {m}m {s}s**.") db.claim_daily(user_id, DAILY_CASH, DAILY_FREE_SPINS) stats = db.get_full_stats(user_id) embed = discord.Embed( title="๐ŸŽ Daily Bonus!", description=(f"You received **${DAILY_CASH}** and **{DAILY_FREE_SPINS} free spins**!\n" - f"New balance: **${stats['cash']}**, Free spins: **{stats['free_spins']}**"), + f"New balance: **${stats['cash']}**, Free spins: **${stats['free_spins']}**"), color=discord.Color.green() ) await ctx.send(embed=embed) @@ -256,7 +331,8 @@ class Economy(commands.Cog): @commands.command(name="leaderboard", aliases=["lb"]) async def leaderboard(self, ctx): rows = db.top_cash(10) - if not rows: return await ctx.send("No players found!") + if not rows: + return await ctx.send("No players found!") lines = [] for i, (uid, cash) in enumerate(rows, 1): name = await self.resolve_name(ctx, uid) @@ -311,6 +387,7 @@ class Economy(commands.Cog): if err == "INSUFFICIENT_FUNDS": return await ctx.send(f"๐Ÿ’ธ Not enough cash. Your balance: ${from_cash}") if err == "SAME_USER": return await ctx.send("๐Ÿชž You canโ€™t tip yourself.") return await ctx.send("โŒ Couldnโ€™t complete the tip. Try again in a moment.") + # names sender = await self.resolve_name(ctx, ctx.author.id) recv = await self.resolve_name(ctx, member.id) desc = f"**{sender}** โ†’ **{recv}**\nAmount: **${amt:,}**" @@ -322,4 +399,3 @@ class Economy(commands.Cog): async def setup(bot): await bot.add_cog(Economy(bot)) - diff --git a/src/cogs/hilo.py b/src/cogs/hilo.py new file mode 100644 index 0000000..5585b4a --- /dev/null +++ b/src/cogs/hilo.py @@ -0,0 +1,357 @@ +# src/cogs/hilo.py +import random +import discord +from discord.ext import commands +from typing import Optional + +from .. import db + +# ---- Config (safe fallbacks; override in constants.py if you like) ---- +try: + from ..utils.constants import HILO_EDGE_PER_STEP, HILO_MAX_MULT, HILO_MIN_BET +except Exception: + HILO_EDGE_PER_STEP = 0.02 # 2% house edge per correct step + HILO_MAX_MULT = 50.0 # cap total multiplier + HILO_MIN_BET = 10 + +SUITS = ["โ™ ๏ธ","โ™ฅ๏ธ","โ™ฆ๏ธ","โ™ฃ๏ธ"] +RANKS = ["A","2","3","4","5","6","7","8","9","10","J","Q","K"] +RANK_TO_VAL = {r:i+1 for i,r in enumerate(RANKS)} # A=1 ... K=13 + +def draw_card(): + return random.choice(RANKS), random.choice(SUITS) + +def step_multiplier(cur_rank_val: int, direction: str) -> float: + """ + Ties are 'push' (no change), so effective trial space ignores equals. + Wins among 12 non-equal ranks. + - Higher: wins = 13 - cur + - Lower: wins = cur - 1 + Fair step mult = 12 / wins. Apply house edge. Clamp at 1.0 for guaranteed steps. + """ + if direction == "up": + wins = 13 - cur_rank_val + else: + wins = cur_rank_val - 1 + if wins <= 0: + return 1.0 + fair = 12.0 / wins + mult = fair * (1.0 - HILO_EDGE_PER_STEP) + return max(1.0, mult) + +def card_str(rank: str, suit: str) -> str: + return f"{rank}{suit}" + +def fmt_money(n: int) -> str: + return f"${n:,}" + +class SetBetModal(discord.ui.Modal, title="Set Hi-Lo Bet"): + def __init__(self, view: "HiloView"): + super().__init__() + self.view = view + self.amount = discord.ui.TextInput( + label=f"Amount (min {HILO_MIN_BET})", + placeholder=str(self.view.bet), + required=True, min_length=1, max_length=10, + ) + self.add_item(self.amount) + + async def on_submit(self, itx: discord.Interaction): + if itx.user.id != self.view.user_id: + return await itx.response.send_message("This isnโ€™t your Hi-Lo panel.", ephemeral=True) + if self.view.in_round: + return await itx.response.send_message("You canโ€™t change bet during a round.", ephemeral=True) + raw = str(self.amount.value) + try: + amt = int("".join(ch for ch in raw if ch.isdigit())) + except Exception: + return await itx.response.send_message("Enter a valid number.", ephemeral=True) + self.view.bet = max(HILO_MIN_BET, amt) + await itx.response.defer(ephemeral=True) + if self.view.message: + await self.view.message.edit(embed=self.view.render(), view=self.view) + +class HiloView(discord.ui.View): + """ + Row 0: Set Bet ยท ร—2 ยท ยฝ ยท Start + Row 1: LOWER ยท Cash Out ยท HIGHER + """ + def __init__(self, user_id: int, initial_bet: int = 50, *, timeout: int = 180): + super().__init__(timeout=timeout) + self.user_id = user_id + self.bet = max(HILO_MIN_BET, initial_bet) + + # round state + self.in_round = False + self.current_card: Optional[tuple[str,str]] = None + self.mult: float = 1.0 + self.steps: int = 0 + self.last_note: Optional[str] = None + self.message: Optional[discord.Message] = None + + # NEW: summary of the last finished round (loss or cashout) + # keys: 'from','to','guess','net','returned','steps','mult' + self.last_summary: Optional[dict] = None + + self._busy = False + self._sync_buttons() + + def disable_all(self): + for c in self.children: + c.disabled = True + + async def on_timeout(self): + self.disable_all() + try: + if self.message: await self.message.edit(view=self) + except: pass + + # ----- rendering ----- + def render(self) -> discord.Embed: + cash, _ = db.get_wallet(self.user_id) + + # Pick color based on state / last result + color = discord.Color.blurple() + if self.in_round: + color = discord.Color.green() + elif self.last_summary is not None: + net = self.last_summary.get("net", 0) + if net > 0: color = discord.Color.green() + elif net < 0: color = discord.Color.red() + else: color = discord.Color.orange() + + e = discord.Embed(title="๐Ÿ”ผ Hi-Lo", color=color) + + if not self.in_round or not self.current_card: + desc = [ + "Set your **bet**, press **Start**, then guess **Higher** or **Lower**.", + "Ties **push** (no gain/loss). **Cash Out** anytime.", + "", + f"**Current bet:** {fmt_money(self.bet)}", + f"**Balance:** {fmt_money(cash)}", + ] + # Show last round summary if available + if self.last_summary is not None: + sym = "๐Ÿ’ฐ" if self.last_summary["net"] > 0 else ("โŒ" if self.last_summary["net"] < 0 else "โ†”๏ธ") + guess = self.last_summary.get("guess", "") + fromc = self.last_summary.get("from") + toc = self.last_summary.get("to") + steps = self.last_summary.get("steps", 0) + mult = self.last_summary.get("mult", 1.0) + returned = self.last_summary.get("returned", 0) + net = self.last_summary.get("net", 0) + + line1 = f"**Last Result:** {sym} " \ + f"{'Cashed out' if guess=='CASHOUT' else f'Guess {guess}'}" + if fromc and toc: + line1 += f" โ€” {card_str(*fromc)} โ†’ {card_str(*toc)}" + elif fromc: + line1 += f" โ€” start {card_str(*fromc)}" + + line2 = f"Steps: **{steps}**, Mult: **ร—{mult:.2f}**, " \ + f"Returned: **{fmt_money(returned)}**, Net: **{'+' if net>0 else ''}{fmt_money(net)}**" + desc += ["", line1, line2] + + e.description = "\n".join(desc) + return e + + # In-round panel + rank, suit = self.current_card + cur_val = RANK_TO_VAL[rank] + up_mult = step_multiplier(cur_val, "up") + dn_mult = step_multiplier(cur_val, "down") + + e.add_field(name="Current Card", value=f"**{card_str(rank, suit)}**", inline=True) + e.add_field(name="Steps", value=str(self.steps), inline=True) + e.add_field(name="Multiplier", value=f"x{self.mult:.2f}", inline=True) + e.add_field(name="Potential Cashout", value=fmt_money(int(self.bet * self.mult)), inline=True) + e.add_field(name="Next Step (Higher)", value=f"ร—{up_mult:.2f}", inline=True) + e.add_field(name="Next Step (Lower)", value=f"ร—{dn_mult:.2f}", inline=True) + + if self.last_note: + e.set_footer(text=self.last_note) + return e + + # ----- helpers ----- + def _sync_buttons(self): + hand_active = self.in_round and self.current_card is not None + # Row 0 + for lbl in ("Set Bet","ร—2","ยฝ","Start"): + b = self._btn(lbl) + if b: b.disabled = hand_active + # Row 1 + for lbl in ("LOWER","Cash Out","HIGHER"): + b = self._btn(lbl) + if b: b.disabled = not hand_active + + # Edge cases: disable impossible guesses + if hand_active and self.current_card: + cur = RANK_TO_VAL[self.current_card[0]] + if self._btn("LOWER"): + self._btn("LOWER").disabled = self._btn("LOWER").disabled or (cur == 1) + if self._btn("HIGHER"): + self._btn("HIGHER").disabled = self._btn("HIGHER").disabled or (cur == 13) + + def _btn(self, label): + for c in self.children: + if isinstance(c, discord.ui.Button) and c.label == label: + return c + return None + + def _reset_round(self): + self.in_round = False + self.current_card = None + self.mult = 1.0 + self.steps = 0 + self.last_note = None + self._sync_buttons() + + async def _start_round(self, itx: discord.Interaction): + # debit bet up front + cash, _ = db.get_wallet(self.user_id) + if self.bet > cash: + return await itx.response.send_message("Not enough cash for that bet.", ephemeral=True) + + db.add_cash(self.user_id, -self.bet) + self.in_round = True + self.mult = 1.0 + self.steps = 0 + self.last_note = None + self.current_card = draw_card() + self._sync_buttons() + await itx.response.edit_message(embed=self.render(), view=self) + + async def _resolve_step(self, itx: discord.Interaction, direction: str): + if self._busy: return + self._busy = True + + from_rank, from_suit = self.current_card + cur_val = RANK_TO_VAL[from_rank] + + # draw until not equal (ties push) + while True: + nrank, nsuit = draw_card() + if nrank != from_rank: + break + # gentle note, no state change + self.last_note = "Tie (same rank) โ€” try again." + await itx.response.edit_message(embed=self.render(), view=self) + self._busy = False + return # require another click to proceed + + win = (RANK_TO_VAL[nrank] > cur_val) if direction == "up" else (RANK_TO_VAL[nrank] < cur_val) + + if win: + # multiply and advance + self.steps += 1 + self.mult *= step_multiplier(cur_val, "up" if direction == "up" else "down") + self.mult = min(self.mult, HILO_MAX_MULT) + self.current_card = (nrank, nsuit) + self.last_note = "โœ… Correct! Cash out anytime." + self._sync_buttons() + try: + await itx.response.edit_message(embed=self.render(), view=self) + finally: + self._busy = False + return + else: + # lose round: no return, bet already debited + returned = 0 + db.record_hilo(self.user_id, bet=self.bet, return_amount=returned, won=False) + # Save a visible summary BEFORE resetting state + self.last_summary = { + "from": (from_rank, from_suit), + "to": (nrank, nsuit), + "guess": "HIGHER" if direction == "up" else "LOWER", + "returned": returned, + "net": -self.bet, + "steps": self.steps, + "mult": self.mult, + } + self._reset_round() + await itx.response.edit_message( + embed=self.render(), view=self + ) + self._busy = False + return + + async def _cashout(self, itx: discord.Interaction): + if not self.in_round: + return await itx.response.send_message("No active round.", ephemeral=True) + returned = int(self.bet * self.mult) + if returned > 0: + db.add_cash(self.user_id, returned) + db.record_hilo(self.user_id, bet=self.bet, return_amount=returned, won=(returned > self.bet)) + + # Save summary for post-round panel + self.last_summary = { + "from": self.current_card, + "to": None, + "guess": "CASHOUT", + "returned": returned, + "net": returned - self.bet, + "steps": self.steps, + "mult": self.mult, + } + self._reset_round() + await itx.response.edit_message(embed=self.render(), view=self) + + # ----- Row 0 ----- + @discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_bet(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("This isnโ€™t your Hi-Lo panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("You canโ€™t change bet during a round.", ephemeral=True) + await itx.response.send_modal(SetBetModal(self)) + + @discord.ui.button(label="ร—2", style=discord.ButtonStyle.secondary, row=0) + async def x2(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("You canโ€™t change bet during a round.", ephemeral=True) + self.bet = max(HILO_MIN_BET, self.bet * 2) + await itx.response.edit_message(embed=self.render(), view=self) + + @discord.ui.button(label="ยฝ", style=discord.ButtonStyle.secondary, row=0) + async def half(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("You canโ€™t change bet during a round.", ephemeral=True) + self.bet = max(HILO_MIN_BET, self.bet // 2) + await itx.response.edit_message(embed=self.render(), view=self) + + @discord.ui.button(label="Start", style=discord.ButtonStyle.success, emoji="๐ŸŽด", row=0) + async def start(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("Already in a round.", ephemeral=True) + await self._start_round(itx) + + # ----- Row 1 ----- + @discord.ui.button(label="LOWER", style=discord.ButtonStyle.danger, emoji="โฌ‡๏ธ", row=1) + async def lower(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if not self.in_round or not self.current_card: return + await self._resolve_step(itx, "down") + + @discord.ui.button(label="Cash Out", style=discord.ButtonStyle.secondary, emoji="๐Ÿ’ต", row=1) + async def cash(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if not self.in_round: return + await self._cashout(itx) + + @discord.ui.button(label="HIGHER", style=discord.ButtonStyle.primary, emoji="โฌ†๏ธ", row=1) + async def higher(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if not self.in_round or not self.current_card: return + await self._resolve_step(itx, "up") + +class HiloCog(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="hilo", aliases=["hi"]) + async def hilo(self, ctx: commands.Context, bet: int = None): + """Open the Hi-Lo panel.""" + uid = ctx.author.id + view = HiloView(uid, initial_bet=bet or 50, timeout=180) + msg = await ctx.send(embed=view.render(), view=view) + view.message = msg + +async def setup(bot): + await bot.add_cog(HiloCog(bot)) diff --git a/src/cogs/mines.py b/src/cogs/mines.py new file mode 100644 index 0000000..5cbabb7 --- /dev/null +++ b/src/cogs/mines.py @@ -0,0 +1,450 @@ +# src/cogs/mines.py +import random +import discord +from discord.ext import commands +from typing import Optional, Set, Tuple + +from .. import db + +# --- Config (override via constants.py if present) --- +try: + from ..utils.constants import ( + MINES_MIN_BET, MINES_EDGE_PER_STEP, MINES_MAX_MULT, MINES_CHOICES + ) +except Exception: + MINES_MIN_BET = 10 + MINES_EDGE_PER_STEP = 0.015 # 1.5% house edge per safe pick + MINES_MAX_MULT = 1000.0 + MINES_CHOICES = [1, 3, 5, 7] # selectable mine counts + +GRID_N = 5 +TOTAL_CELLS = GRID_N * GRID_N + +# emojis +EMO_UNREVEALED = "โฌœ" +EMO_SAFE = "๐ŸŸฉ" +EMO_MINE = "๐Ÿ’ฃ" +EMO_HIT = "๐Ÿ’ฅ" # bomb that was clicked + +# labels +COL_LABELS = ["๐Ÿ‡ฆ","๐Ÿ‡ง","๐Ÿ‡จ","๐Ÿ‡ฉ","๐Ÿ‡ช"] +ROW_LABELS = ["1๏ธโƒฃ","2๏ธโƒฃ","3๏ธโƒฃ","4๏ธโƒฃ","5๏ธโƒฃ"] + +def money(n: int) -> str: + return f"${n:,}" + +def coord_to_idx(cell: Tuple[int,int]) -> int: + r, c = cell + return r * GRID_N + c + +def step_multiplier(total_rem: int, safe_rem: int) -> float: + """ + Fair step MULT = 1 / P(safe) = total_rem / safe_rem. + Apply per-step house edge, clamp to >= 1.0 and <= cap. + """ + if safe_rem <= 0 or total_rem <= 0: + return 1.0 + fair = total_rem / safe_rem + mult = fair * (1.0 - MINES_EDGE_PER_STEP) + return max(1.0, min(mult, MINES_MAX_MULT)) + +class SetBetModal(discord.ui.Modal, title="Set Mines Bet"): + def __init__(self, view: "MinesView"): + super().__init__() + self.view = view + self.amount = discord.ui.TextInput( + label=f"Amount (min {MINES_MIN_BET})", + placeholder=str(self.view.bet), + required=True, min_length=1, max_length=10, + ) + self.add_item(self.amount) + + async def on_submit(self, itx: discord.Interaction): + if itx.user.id != self.view.user_id: + return await itx.response.send_message("This isnโ€™t your Mines panel.", ephemeral=True) + if self.view.in_round: + return await itx.response.send_message("You canโ€™t change bet during a round.", ephemeral=True) + raw = str(self.amount.value) + try: + amt = int("".join(ch for ch in raw if ch.isdigit())) + except Exception: + return await itx.response.send_message("Enter a valid number.", ephemeral=True) + self.view.bet = max(MINES_MIN_BET, amt) + await itx.response.defer(ephemeral=True) + if self.view.message: + await self.view.message.edit(embed=self.view.render(), view=self.view) + +class PickCellModal(discord.ui.Modal, title="Pick a Cell (A1โ€“E5)"): + def __init__(self, view: "MinesView"): + super().__init__() + self.view = view + self.cell = discord.ui.TextInput( + label="Cell (e.g., A1, C3, E5)", + placeholder="A1", + required=True, + min_length=2, max_length=3, + ) + self.add_item(self.cell) + + async def on_submit(self, itx: discord.Interaction): + if itx.user.id != self.view.user_id: + return await itx.response.send_message("This isnโ€™t your Mines panel.", ephemeral=True) + if not self.view.in_round: + return await itx.response.send_message("No active round.", ephemeral=True) + raw = self.cell.value.strip().upper().replace(" ", "") + if len(raw) < 2: + return await itx.response.send_message("Invalid cell.", ephemeral=True) + col_ch = raw[0] + row_str = raw[1:] + if col_ch not in "ABCDE": + return await itx.response.send_message("Column must be Aโ€“E.", ephemeral=True) + if not row_str.isdigit(): + return await itx.response.send_message("Row must be 1โ€“5.", ephemeral=True) + r = int(row_str) - 1 + c = "ABCDE".index(col_ch) + if r < 0 or r >= GRID_N or c < 0 or c >= GRID_N: + return await itx.response.send_message("Out of range.", ephemeral=True) + await itx.response.defer(ephemeral=True) + await self.view.reveal_cell((r, c)) + +class MinesView(discord.ui.View): + """ + Row 0: Set Bet ยท ร—2 ยท ยฝ ยท Mines:[N] ยท Start + Row 1: Pick Cell ยท Pick Random ยท Cash Out + """ + def __init__(self, user_id: int, initial_bet: int = 50, *, timeout: int = 180): + super().__init__(timeout=timeout) + self.user_id = user_id + self.bet = max(MINES_MIN_BET, initial_bet) + + self.mines_options = MINES_CHOICES[:] + self.mines_index = 0 + self.mines_count = self.mines_options[self.mines_index] + + # round state + self.in_round = False + self.mines: Set[int] = set() + self.revealed: Set[int] = set() + self.mult = 1.0 + self.total_rem = TOTAL_CELLS + self.safe_rem = TOTAL_CELLS - self.mines_count + + self.last_summary: Optional[dict] = None # show after round ends + self.message: Optional[discord.Message] = None + self._busy = False + + self._sync_buttons() + + # ----- lifecycle ----- + def disable_all(self): + for c in self.children: + c.disabled = True + + async def on_timeout(self): + self.disable_all() + try: + if self.message: + await self.message.edit(view=self) + except: + pass + + # ----- rendering ----- + def _grid_text( + self, + show_mines: bool = False, + with_labels: bool = True, + *, + mines: Optional[Set[int]] = None, + revealed: Optional[Set[int]] = None, + hit_idx: Optional[int] = None + ) -> str: + """Return a labeled 5ร—5 grid as lines of emojis. Can render from snapshots.""" + mines = self.mines if mines is None else mines + revealed = self.revealed if revealed is None else revealed + + lines = [] + if with_labels: + lines.append("โฌ› " + " ".join(COL_LABELS)) + for r in range(GRID_N): + row_emojis = [] + for c in range(GRID_N): + idx = coord_to_idx((r, c)) + if idx in revealed: + # if we want the clicked bomb to show as ๐Ÿ’ฅ even if counted as revealed, prefer ๐Ÿ’ฅ + if show_mines and hit_idx is not None and idx == hit_idx: + row_emojis.append(EMO_HIT) + else: + row_emojis.append(EMO_SAFE) + else: + if show_mines and idx in mines: + row_emojis.append(EMO_HIT if (hit_idx is not None and idx == hit_idx) else EMO_MINE) + else: + row_emojis.append(EMO_UNREVEALED) + line = " ".join(row_emojis) + if with_labels: + line = f"{ROW_LABELS[r]} {line}" + lines.append(line) + return "\n".join(lines) + + def render(self) -> discord.Embed: + # color based on last result or active + if self.in_round: + color = discord.Color.green() + elif self.last_summary is not None: + net = self.last_summary.get("net", 0) + color = discord.Color.green() if net > 0 else (discord.Color.red() if net < 0 else discord.Color.orange()) + else: + color = discord.Color.blurple() + + e = discord.Embed(title="๐Ÿ’ฃ Mines", color=color) + cash, _ = db.get_wallet(self.user_id) + + if not self.in_round: + desc = [ + f"**Bet:** {money(self.bet)} โ€ข **Mines:** {self.mines_count}", + f"**Balance:** {money(cash)}", + "", + "Press **Start** to place mines and begin. Then **Pick Cell** (A1โ€“E5) or **Pick Random**.", + "Each safe pick increases your multiplier; **Cash Out** anytime.", + ] + if self.last_summary is not None: + sym = "๐Ÿ’ฐ" if self.last_summary["net"] > 0 else ("โŒ" if self.last_summary["net"] < 0 else "โ†”๏ธ") + returned = self.last_summary["returned"] + net = self.last_summary["net"] + picks = self.last_summary["picks"] + mult = self.last_summary["mult"] + how = self.last_summary["how"] + if how == "MINE": + where = self.last_summary.get("cell_str", "?") + desc += [ + "", + f"**Last Result:** {sym} Hit a mine at **{where}**.", + f"Safe picks: **{picks}**, Mult: **ร—{mult:.2f}**, Returned: **{money(returned)}**, Net: **{'+' if net>0 else ''}{money(net)}**", + ] + else: + desc += [ + "", + f"**Last Result:** {sym} Cashed out.", + f"Safe picks: **{picks}**, Mult: **ร—{mult:.2f}**, Returned: **{money(returned)}**, Net: **{'+' if net>0 else ''}{money(net)}**", + ] + e.description = "\n".join(desc) + + # NEW: show snapshot board if present + if self.last_summary is not None and self.last_summary.get("board"): + e.add_field(name="Board", value=self.last_summary["board"], inline=False) + + return e + + # in-round panel + grid = self._grid_text(show_mines=False, with_labels=True) + e.description = ( + f"**Bet:** {money(self.bet)} โ€ข **Mines:** {self.mines_count}\n" + f"**Picks:** {len(self.revealed)} โ€ข **Multiplier:** ร—{self.mult:.2f} โ€ข " + f"**Cashout:** {money(int(self.bet * self.mult))}\n\n{grid}" + ) + e.set_footer(text="Use Pick Cell (A1โ€“E5) or Pick Random. Cash Out anytime.") + return e + + # ----- helpers ----- + def _sync_buttons(self): + active = self.in_round + # Row 0 controls: disabled in-round (except Start which is disabled once in-round) + for lbl in ("Set Bet", "ร—2", "ยฝ", None, "Start"): + b = self._btn(lbl) if lbl else None + if b: + b.disabled = active if lbl != "Start" else active + # Mines button enabled only when not in round + mines_btn = self._btn(f"Mines: {self.mines_count}") + if mines_btn: + mines_btn.disabled = active + + # Row 1 controls: enabled only in-round + for lbl in ("Pick Cell", "Pick Random", "Cash Out"): + b = self._btn(lbl) + if b: b.disabled = not active + + def _btn(self, label: Optional[str]): + for c in self.children: + if isinstance(c, discord.ui.Button) and c.label == label: + return c + return None + + def _reset_round(self): + self.in_round = False + self.mines.clear() + self.revealed.clear() + self.mult = 1.0 + self.total_rem = TOTAL_CELLS + self.safe_rem = TOTAL_CELLS - self.mines_count + self._sync_buttons() + + async def start_round(self, itx: discord.Interaction): + cash, _ = db.get_wallet(self.user_id) + if self.bet > cash: + return await itx.response.send_message("Not enough cash for that bet.", ephemeral=True) + + # debit and place mines + db.add_cash(self.user_id, -self.bet) + self.in_round = True + self.mult = 1.0 + self.revealed = set() + all_idx = list(range(TOTAL_CELLS)) + self.mines = set(random.sample(all_idx, self.mines_count)) + self.total_rem = TOTAL_CELLS + self.safe_rem = TOTAL_CELLS - self.mines_count + self._sync_buttons() + await itx.response.edit_message(embed=self.render(), view=self) + + def _cell_str(self, rc: Tuple[int,int]) -> str: + col = "ABCDE"[rc[1]] + row = rc[0] + 1 + return f"{col}{row}" + + async def reveal_cell(self, rc: Tuple[int,int]): + if self._busy or not self.in_round: + return + self._busy = True + idx = coord_to_idx(rc) + + # already revealed? + if idx in self.revealed: + self._busy = False + return + + if idx in self.mines: + # build snapshot BEFORE resetting: show all mines and mark the hit + snapshot_board = self._grid_text( + show_mines=True, with_labels=True, + mines=set(self.mines), revealed=set(self.revealed), hit_idx=idx + ) + + returned = 0 + db.record_mines(self.user_id, bet=self.bet, return_amount=returned, won=False) + self.last_summary = { + "how": "MINE", + "cell_str": self._cell_str(rc), + "picks": len(self.revealed), + "mult": self.mult, + "returned": returned, + "net": -self.bet, + "board": snapshot_board, + } + self._reset_round() + if self.message: + await self.message.edit(embed=self.render(), view=self) + self._busy = False + return + + # success โ€” update multiplier and state + step_mult = step_multiplier(self.total_rem, self.safe_rem) + self.mult = min(self.mult * step_mult, MINES_MAX_MULT) + self.revealed.add(idx) + self.total_rem -= 1 + self.safe_rem -= 1 + + if self.message: + await self.message.edit(embed=self.render(), view=self) + self._busy = False + + async def pick_random(self, itx: discord.Interaction): + # choose any unrevealed cell uniformly + unrevealed = [i for i in range(TOTAL_CELLS) if i not in self.revealed] + if not unrevealed: + return await itx.response.send_message("All cells are revealed!", ephemeral=True) + choice = random.choice(unrevealed) + r, c = divmod(choice, GRID_N) + await itx.response.defer(ephemeral=True) + await self.reveal_cell((r, c)) + + async def cash_out(self, itx: discord.Interaction): + if not self.in_round: + return await itx.response.send_message("No active round.", ephemeral=True) + returned = int(self.bet * self.mult) + if returned > 0: + db.add_cash(self.user_id, returned) + db.record_mines(self.user_id, bet=self.bet, return_amount=returned, won=(returned > self.bet)) + + # snapshot of the revealed greens at cashout + snapshot_board = self._grid_text( + show_mines=False, with_labels=True, + mines=set(self.mines), revealed=set(self.revealed) + ) + self.last_summary = { + "how": "CASH", + "picks": len(self.revealed), + "mult": self.mult, + "returned": returned, + "net": returned - self.bet, + "board": snapshot_board, + } + self._reset_round() + await itx.response.edit_message(embed=self.render(), view=self) + + # ----- Row 0 controls ----- + @discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_bet(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("You canโ€™t change bet during a round.", ephemeral=True) + await itx.response.send_modal(SetBetModal(self)) + + @discord.ui.button(label="ร—2", style=discord.ButtonStyle.secondary, row=0) + async def x2(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("You canโ€™t change bet during a round.", ephemeral=True) + self.bet = max(MINES_MIN_BET, self.bet * 2) + await itx.response.edit_message(embed=self.render(), view=self) + + @discord.ui.button(label="ยฝ", style=discord.ButtonStyle.secondary, row=0) + async def half(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("You canโ€™t change bet during a round.", ephemeral=True) + self.bet = max(MINES_MIN_BET, self.bet // 2) + await itx.response.edit_message(embed=self.render(), view=self) + + @discord.ui.button(label="Start", style=discord.ButtonStyle.success, emoji="๐Ÿšฉ", row=0) + async def start(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("Already in a round.", ephemeral=True) + await self.start_round(itx) + + # "Mines: N" (cycles through your MINES_CHOICES) + @discord.ui.button(label="Mines: 1", style=discord.ButtonStyle.secondary, row=0) + async def mines_btn(self, itx: discord.Interaction, button: discord.ui.Button): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.in_round: return await itx.response.send_message("Change mines before starting.", ephemeral=True) + self.mines_index = (self.mines_index + 1) % len(self.mines_options) + self.mines_count = self.mines_options[self.mines_index] + button.label = f"Mines: {self.mines_count}" + await itx.response.edit_message(embed=self.render(), view=self) + + # ----- Row 1 controls ----- + @discord.ui.button(label="Pick Cell", style=discord.ButtonStyle.primary, emoji="๐ŸŽฏ", row=1) + async def pick_cell(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if not self.in_round: return await itx.response.send_message("No active round.", ephemeral=True) + await itx.response.send_modal(PickCellModal(self)) + + @discord.ui.button(label="Pick Random", style=discord.ButtonStyle.secondary, emoji="๐ŸŽฒ", row=1) + async def pick_rand(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if not self.in_round: return await itx.response.send_message("No active round.", ephemeral=True) + await self.pick_random(itx) + + @discord.ui.button(label="Cash Out", style=discord.ButtonStyle.danger, emoji="๐Ÿ’ต", row=1) + async def cash(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if not self.in_round: return await itx.response.send_message("No active round.", ephemeral=True) + await self.cash_out(itx) + +class MinesCog(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="mines") + async def mines(self, ctx: commands.Context, bet: int = None): + uid = ctx.author.id + view = MinesView(uid, initial_bet=bet or MINES_MIN_BET, timeout=180) + msg = await ctx.send(embed=view.render(), view=view) + view.message = msg + +async def setup(bot): + await bot.add_cog(MinesCog(bot)) diff --git a/src/cogs/packs.py b/src/cogs/packs.py new file mode 100644 index 0000000..f5724e5 --- /dev/null +++ b/src/cogs/packs.py @@ -0,0 +1,469 @@ +# src/cogs/packs.py +import random +import discord +from discord.ext import commands +from typing import Optional, Dict, List, Tuple + +from .. import db + +# ---- Tunables (override via constants.py if you want) ---- +try: + from ..utils.constants import PACK_MIN_BET, PACK_SIZE, PACK_RARITY_WEIGHTS +except Exception: + PACK_MIN_BET = 50 + PACK_SIZE = 5 + # weights per rarity (sum doesn't need to be 1; we normalize) + PACK_RARITY_WEIGHTS = { + "Common": 600, + "Uncommon": 250, + "Rare": 100, + "Epic": 40, + "Legendary": 9, + "Mythic": 1, + } + +# ----------------------------------------------------------------------------- +# Catalog: id, emoji, display name, rarity, multiplier (ร— bet) +# Kept your original 25 items & IDs; expanded to 100 total. +# ----------------------------------------------------------------------------- +CATALOG: List[Dict] = [ + # ---------------------- Common (40) ---------------------- + {"id":"cherry","emoji":"๐Ÿ’","name":"Cherries","rarity":"Common","mult":0.10}, + {"id":"coin","emoji":"๐Ÿช™","name":"Coin","rarity":"Common","mult":0.12}, + {"id":"mug","emoji":"โ˜•","name":"Mug","rarity":"Common","mult":0.15}, + {"id":"leaf","emoji":"๐ŸŒฟ","name":"Leaf","rarity":"Common","mult":0.18}, + {"id":"sock","emoji":"๐Ÿงฆ","name":"Sock","rarity":"Common","mult":0.20}, + {"id":"teddy","emoji":"๐Ÿงธ","name":"Teddy","rarity":"Common","mult":0.22}, + {"id":"ice","emoji":"๐ŸงŠ","name":"Ice","rarity":"Common","mult":0.24}, + {"id":"clover","emoji":"๐Ÿ€","name":"Clover","rarity":"Common","mult":0.25}, + + {"id":"apple","emoji":"๐ŸŽ","name":"Apple","rarity":"Common","mult":0.12}, + {"id":"banana","emoji":"๐ŸŒ","name":"Banana","rarity":"Common","mult":0.14}, + {"id":"pear","emoji":"๐Ÿ","name":"Pear","rarity":"Common","mult":0.13}, + {"id":"peach","emoji":"๐Ÿ‘","name":"Peach","rarity":"Common","mult":0.16}, + {"id":"strawberry","emoji":"๐Ÿ“","name":"Strawberry","rarity":"Common","mult":0.18}, + {"id":"grapes","emoji":"๐Ÿ‡","name":"Grapes","rarity":"Common","mult":0.20}, + {"id":"carrot","emoji":"๐Ÿฅ•","name":"Carrot","rarity":"Common","mult":0.12}, + {"id":"corn","emoji":"๐ŸŒฝ","name":"Corn","rarity":"Common","mult":0.15}, + {"id":"bread","emoji":"๐Ÿž","name":"Bread","rarity":"Common","mult":0.14}, + {"id":"cheese","emoji":"๐Ÿง€","name":"Cheese","rarity":"Common","mult":0.18}, + {"id":"egg","emoji":"๐Ÿฅš","name":"Egg","rarity":"Common","mult":0.16}, + {"id":"milk","emoji":"๐Ÿฅ›","name":"Milk","rarity":"Common","mult":0.12}, + {"id":"mushroom","emoji":"๐Ÿ„","name":"Mushroom","rarity":"Common","mult":0.20}, + {"id":"seedling","emoji":"๐ŸŒฑ","name":"Seedling","rarity":"Common","mult":0.16}, + {"id":"flower","emoji":"๐ŸŒผ","name":"Flower","rarity":"Common","mult":0.16}, + {"id":"maple","emoji":"๐Ÿ","name":"Maple Leaf","rarity":"Common","mult":0.18}, + {"id":"sun","emoji":"โ˜€๏ธ","name":"Sun","rarity":"Common","mult":0.24}, + {"id":"moon","emoji":"๐ŸŒ™","name":"Moon","rarity":"Common","mult":0.22}, + {"id":"cloud","emoji":"โ˜๏ธ","name":"Cloud","rarity":"Common","mult":0.18}, + {"id":"snowflake","emoji":"โ„๏ธ","name":"Snowflake","rarity":"Common","mult":0.20}, + {"id":"umbrella","emoji":"โ˜‚๏ธ","name":"Umbrella","rarity":"Common","mult":0.18}, + {"id":"balloon","emoji":"๐ŸŽˆ","name":"Balloon","rarity":"Common","mult":0.22}, + {"id":"pencil","emoji":"โœ๏ธ","name":"Pencil","rarity":"Common","mult":0.14}, + {"id":"book","emoji":"๐Ÿ“˜","name":"Book","rarity":"Common","mult":0.16}, + {"id":"paperclip","emoji":"๐Ÿ–‡๏ธ","name":"Paperclip","rarity":"Common","mult":0.14}, + {"id":"scissors","emoji":"โœ‚๏ธ","name":"Scissors","rarity":"Common","mult":0.16}, + {"id":"bulb","emoji":"๐Ÿ’ก","name":"Light Bulb","rarity":"Common","mult":0.24}, + {"id":"battery","emoji":"๐Ÿ”‹","name":"Battery","rarity":"Common","mult":0.22}, + {"id":"wrench","emoji":"๐Ÿ”ง","name":"Wrench","rarity":"Common","mult":0.20}, + {"id":"hammer","emoji":"๐Ÿ”จ","name":"Hammer","rarity":"Common","mult":0.20}, + {"id":"camera","emoji":"๐Ÿ“ท","name":"Camera","rarity":"Common","mult":0.24}, + {"id":"gamepad","emoji":"๐ŸŽฎ","name":"Gamepad","rarity":"Common","mult":0.24}, + + # -------------------- Uncommon (25) --------------------- + {"id":"donut","emoji":"๐Ÿฉ","name":"Donut","rarity":"Uncommon","mult":0.35}, + {"id":"pizza","emoji":"๐Ÿ•","name":"Pizza","rarity":"Uncommon","mult":0.40}, + {"id":"soccer","emoji":"โšฝ","name":"Soccer Ball","rarity":"Uncommon","mult":0.45}, + {"id":"headset","emoji":"๐ŸŽง","name":"Headset","rarity":"Uncommon","mult":0.50}, + {"id":"magnet","emoji":"๐Ÿงฒ","name":"Magnet","rarity":"Uncommon","mult":0.55}, + {"id":"cat","emoji":"๐Ÿฑ","name":"Cat","rarity":"Uncommon","mult":0.60}, + + {"id":"basketball","emoji":"๐Ÿ€","name":"Basketball","rarity":"Uncommon","mult":0.45}, + {"id":"baseball","emoji":"โšพ","name":"Baseball","rarity":"Uncommon","mult":0.42}, + {"id":"guitar","emoji":"๐ŸŽธ","name":"Guitar","rarity":"Uncommon","mult":0.55}, + {"id":"violin","emoji":"๐ŸŽป","name":"Violin","rarity":"Uncommon","mult":0.58}, + {"id":"joystick","emoji":"๐Ÿ•น๏ธ","name":"Joystick","rarity":"Uncommon","mult":0.50}, + {"id":"keyboard","emoji":"โŒจ๏ธ","name":"Keyboard","rarity":"Uncommon","mult":0.48}, + {"id":"laptop","emoji":"๐Ÿ’ป","name":"Laptop","rarity":"Uncommon","mult":0.60}, + {"id":"robot","emoji":"๐Ÿค–","name":"Robot","rarity":"Uncommon","mult":0.62}, + {"id":"dog","emoji":"๐Ÿถ","name":"Dog","rarity":"Uncommon","mult":0.55}, + {"id":"fox","emoji":"๐ŸฆŠ","name":"Fox","rarity":"Uncommon","mult":0.58}, + {"id":"penguin","emoji":"๐Ÿง","name":"Penguin","rarity":"Uncommon","mult":0.60}, + {"id":"koala","emoji":"๐Ÿจ","name":"Koala","rarity":"Uncommon","mult":0.60}, + {"id":"panda","emoji":"๐Ÿผ","name":"Panda","rarity":"Uncommon","mult":0.60}, + {"id":"owl","emoji":"๐Ÿฆ‰","name":"Owl","rarity":"Uncommon","mult":0.65}, + {"id":"butterfly","emoji":"๐Ÿฆ‹","name":"Butterfly","rarity":"Uncommon","mult":0.65}, + {"id":"car","emoji":"๐Ÿš—","name":"Car","rarity":"Uncommon","mult":0.55}, + {"id":"train","emoji":"๐Ÿš†","name":"Train","rarity":"Uncommon","mult":0.55}, + {"id":"sailboat","emoji":"โ›ต","name":"Sailboat","rarity":"Uncommon","mult":0.58}, + {"id":"airplane","emoji":"โœˆ๏ธ","name":"Airplane","rarity":"Uncommon","mult":0.62}, + + # ---------------------- Rare (18) ----------------------- + {"id":"melon","emoji":"๐Ÿ‰","name":"Watermelon","rarity":"Rare","mult":0.85}, + {"id":"tiger","emoji":"๐Ÿฏ","name":"Tiger","rarity":"Rare","mult":1.00}, + {"id":"ufo","emoji":"๐Ÿ›ธ","name":"UFO","rarity":"Rare","mult":1.10}, + {"id":"unicorn","emoji":"๐Ÿฆ„","name":"Unicorn","rarity":"Rare","mult":1.20}, + + {"id":"elephant","emoji":"๐Ÿ˜","name":"Elephant","rarity":"Rare","mult":0.95}, + {"id":"lion","emoji":"๐Ÿฆ","name":"Lion","rarity":"Rare","mult":1.05}, + {"id":"wolf","emoji":"๐Ÿบ","name":"Wolf","rarity":"Rare","mult":1.05}, + {"id":"dolphin","emoji":"๐Ÿฌ","name":"Dolphin","rarity":"Rare","mult":0.90}, + {"id":"whale","emoji":"๐Ÿณ","name":"Whale","rarity":"Rare","mult":0.90}, + {"id":"alien","emoji":"๐Ÿ‘ฝ","name":"Alien","rarity":"Rare","mult":1.10}, + {"id":"ghost","emoji":"๐Ÿ‘ป","name":"Ghost","rarity":"Rare","mult":1.00}, + {"id":"crystalball","emoji":"๐Ÿ”ฎ","name":"Crystal Ball","rarity":"Rare","mult":1.10}, + {"id":"satellite","emoji":"๐Ÿ›ฐ๏ธ","name":"Satellite","rarity":"Rare","mult":1.15}, + {"id":"comet","emoji":"โ˜„๏ธ","name":"Comet","rarity":"Rare","mult":1.20}, + {"id":"ninja","emoji":"๐Ÿฅท","name":"Ninja","rarity":"Rare","mult":1.10}, + {"id":"mountain","emoji":"โ›ฐ๏ธ","name":"Mountain","rarity":"Rare","mult":0.95}, + {"id":"volcano","emoji":"๐ŸŒ‹","name":"Volcano","rarity":"Rare","mult":1.00}, + {"id":"ring_rare","emoji":"๐Ÿ’","name":"Ring","rarity":"Rare","mult":1.25}, + + # ---------------------- Epic (10) ----------------------- + {"id":"dragon","emoji":"๐Ÿ‰","name":"Dragon","rarity":"Epic","mult":2.0}, + {"id":"lab","emoji":"๐Ÿงช","name":"Lab Flask","rarity":"Epic","mult":2.5}, + {"id":"rocket","emoji":"๐Ÿš€","name":"Rocket","rarity":"Epic","mult":3.0}, + + {"id":"wizard","emoji":"๐Ÿง™","name":"Wizard","rarity":"Epic","mult":2.2}, + {"id":"wand","emoji":"๐Ÿช„","name":"Magic Wand","rarity":"Epic","mult":2.6}, + {"id":"dragonface","emoji":"๐Ÿฒ","name":"Dragon Face","rarity":"Epic","mult":2.8}, + {"id":"castle","emoji":"๐Ÿฐ","name":"Castle","rarity":"Epic","mult":2.4}, + {"id":"shield","emoji":"๐Ÿ›ก๏ธ","name":"Shield","rarity":"Epic","mult":2.3}, + {"id":"swords","emoji":"โš”๏ธ","name":"Crossed Swords","rarity":"Epic","mult":2.7}, + {"id":"trophy_epic","emoji":"๐Ÿ†","name":"Trophy","rarity":"Epic","mult":3.2}, + + # ------------------- Legendary (5) ---------------------- + {"id":"crown","emoji":"๐Ÿ‘‘","name":"Crown","rarity":"Legendary","mult":6.0}, + {"id":"eagle","emoji":"๐Ÿฆ…","name":"Eagle","rarity":"Legendary","mult":7.5}, + + {"id":"medal","emoji":"๐Ÿฅ‡","name":"Gold Medal","rarity":"Legendary","mult":9.0}, + {"id":"moneybag","emoji":"๐Ÿ’ฐ","name":"Money Bag","rarity":"Legendary","mult":8.5}, + {"id":"ring_legend","emoji":"๐Ÿ’","name":"Royal Ring","rarity":"Legendary","mult":10.0}, + + # --------------------- Mythic (2) ----------------------- + {"id":"dino","emoji":"๐Ÿฆ–","name":"Dino","rarity":"Mythic","mult":25.0}, + {"id":"diamond","emoji":"๐Ÿ’Ž","name":"Diamond","rarity":"Mythic","mult":50.0}, +] + +# Build rarity index +RARITY_TO_ITEMS: Dict[str, List[Dict]] = {} +for it in CATALOG: + RARITY_TO_ITEMS.setdefault(it["rarity"], []).append(it) + +def _norm_weights(weights: Dict[str, float]) -> Dict[str, float]: + s = float(sum(weights.values())) + return {k: (v / s if s > 0 else 0.0) for k, v in weights.items()} + +NORM_RARITY = _norm_weights(PACK_RARITY_WEIGHTS) + +def _weighted_choice(items: List[Tuple[float, Dict]]) -> Dict: + """items: list of (weight, item) where weight >= 0""" + total = sum(w for w, _ in items) + r = random.random() * total + upto = 0.0 + for w, it in items: + upto += w + if r <= upto: + return it + return items[-1][1] + +def pick_one() -> Dict: + # pick rarity + rar_roll = _weighted_choice([(p, {"rarity": r}) for r, p in NORM_RARITY.items()])["rarity"] + pool = RARITY_TO_ITEMS.get(rar_roll, CATALOG) + # equal within rarity + return random.choice(pool) + +def money(n: int) -> str: + return f"${n:,}" + +# ---------- Pagination helpers ---------- +RARITY_RANK = {"Mythic": 0, "Legendary": 1, "Epic": 2, "Rare": 3, "Uncommon": 4, "Common": 5} +SORTED_CATALOG = sorted( + CATALOG, + key=lambda it: (RARITY_RANK.get(it["rarity"], 99), -float(it["mult"]), it["name"]) +) +# Attach rank (1 = rarest) +for i, it in enumerate(SORTED_CATALOG, 1): + it["_rank"] = i + +PAGE_SIZE = 20 # 100 items -> 5 pages + +def _collection_page_lines(user_id: int, page: int) -> Tuple[str, int, int, int]: + """ + Returns (text, start_idx, end_idx, total_pages) for the given page (0-based). + """ + coll = db.get_packs_collection(user_id) + total = len(SORTED_CATALOG) + pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + page = max(0, min(page, pages - 1)) + start = page * PAGE_SIZE + end = min(total, start + PAGE_SIZE) + + lines: List[str] = [] + for it in SORTED_CATALOG[start:end]: + owned = coll.get(it["id"], 0) > 0 + idx = it["_rank"] + if owned: + lines.append(f"**#{idx:02d}** {it['emoji']} **{it['name']}** ยท *{it['rarity']}*") + else: + lines.append(f"**#{idx:02d}** ?") + text = "\n".join(lines) + return text, start, end, pages + +def _collection_embed(user_id: int, page: int) -> discord.Embed: + coll = db.get_packs_collection(user_id) + have = sum(1 for it in SORTED_CATALOG if coll.get(it["id"], 0) > 0) + total = len(SORTED_CATALOG) + text, start, end, pages = _collection_page_lines(user_id, page) + e = discord.Embed( + title="๐Ÿ—‚๏ธ Packs Collection", + description=f"**Progress:** {have}/{total} collected\n" + f"Rarest is **#01**, Common end is **#{total:02d}**.\n\n{text}", + color=discord.Color.dark_gold() + ) + e.set_footer(text=f"Page {page+1}/{pages} โ€ข Showing #{start+1:02d}โ€“#{end:02d}") + return e + +# ---------- UI: Set Bet modal ---------- +class SetBetModal(discord.ui.Modal, title="Set Packs Bet"): + def __init__(self, view: "PacksView"): + super().__init__() + self.view = view + self.amount = discord.ui.TextInput( + label=f"Amount (min {PACK_MIN_BET})", + placeholder=str(self.view.bet), + required=True, min_length=1, max_length=10, + ) + self.add_item(self.amount) + + async def on_submit(self, itx: discord.Interaction): + if itx.user.id != self.view.user_id: + return await itx.response.send_message("This isnโ€™t your Packs panel.", ephemeral=True) + if self.view.busy: + return await itx.response.send_message("Please wait a moment.", ephemeral=True) + raw = str(self.amount.value) + try: + amt = int("".join(ch for ch in raw if ch.isdigit())) + except Exception: + return await itx.response.send_message("Enter a valid number.", ephemeral=True) + self.view.bet = max(PACK_MIN_BET, amt) + await itx.response.defer(ephemeral=True) + if self.view.message: + await self.view.message.edit(embed=self.view.render(), view=self.view) + +# ---------- UI: Collection View ---------- +class PacksCollectionView(discord.ui.View): + def __init__(self, user_id: int, *, start_page: int = 0, timeout: int = 180): + super().__init__(timeout=timeout) + self.user_id = user_id + self.page = start_page + self.pages = max(1, (len(SORTED_CATALOG) + PAGE_SIZE - 1) // PAGE_SIZE) + self.message: Optional[discord.Message] = None + + async def on_timeout(self): + for c in self.children: + c.disabled = True + try: + if self.message: + await self.message.edit(view=self) + except: + pass + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.user_id: + await interaction.response.send_message("This collection belongs to someone else.", ephemeral=True) + return False + return True + + def _sync(self): + # enable/disable nav buttons based on current page + for c in self.children: + if isinstance(c, discord.ui.Button): + if c.custom_id == "first": c.disabled = (self.page <= 0) + if c.custom_id == "prev": c.disabled = (self.page <= 0) + if c.custom_id == "next": c.disabled = (self.page >= self.pages - 1) + if c.custom_id == "last": c.disabled = (self.page >= self.pages - 1) + + def embed(self) -> discord.Embed: + self._sync() + return _collection_embed(self.user_id, self.page) + + @discord.ui.button(label="โฎ๏ธ", style=discord.ButtonStyle.secondary, custom_id="first") + async def first(self, itx: discord.Interaction, _): + self.page = 0 + await itx.response.edit_message(embed=self.embed(), view=self) + + @discord.ui.button(label="โ—€๏ธ", style=discord.ButtonStyle.secondary, custom_id="prev") + async def prev(self, itx: discord.Interaction, _): + if self.page > 0: + self.page -= 1 + await itx.response.edit_message(embed=self.embed(), view=self) + + @discord.ui.button(label="โ–ถ๏ธ", style=discord.ButtonStyle.secondary, custom_id="next") + async def next(self, itx: discord.Interaction, _): + if self.page < self.pages - 1: + self.page += 1 + await itx.response.edit_message(embed=self.embed(), view=self) + + @discord.ui.button(label="โญ๏ธ", style=discord.ButtonStyle.secondary, custom_id="last") + async def last(self, itx: discord.Interaction, _): + self.page = self.pages - 1 + await itx.response.edit_message(embed=self.embed(), view=self) + + @discord.ui.button(label="Close", style=discord.ButtonStyle.danger) + async def close(self, itx: discord.Interaction, _): + try: + await itx.message.delete() + except: + try: + for c in self.children: c.disabled = True + await itx.response.edit_message(view=self) + except: + pass + +# ---------- Packs game UI ---------- +class PacksView(discord.ui.View): + """ + Row 0: Set Bet ยท ร—2 ยท ยฝ ยท Open Pack + Row 1: Open Again ยท Collection + """ + def __init__(self, user_id: int, initial_bet: int = 100, *, timeout: int = 180): + super().__init__(timeout=timeout) + self.user_id = user_id + self.bet = max(PACK_MIN_BET, initial_bet) + self.last_pack: Optional[List[Dict]] = None + self.last_return: int = 0 + self.last_net: int = 0 + self.busy = False + self.message: Optional[discord.Message] = None + + async def on_timeout(self): + for c in self.children: c.disabled = True + try: + if self.message: await self.message.edit(view=self) + except: pass + + # ---- render + def render(self) -> discord.Embed: + color = discord.Color.blurple() + if self.last_pack is not None: + color = discord.Color.green() if self.last_net > 0 else (discord.Color.red() if self.last_net < 0 else discord.Color.orange()) + e = discord.Embed(title="๐Ÿ“ฆ Packs", color=color) + + cash, _ = db.get_wallet(self.user_id) + desc = [f"**Bet:** {money(self.bet)} โ€ข **Balance:** {money(cash)}"] + if self.last_pack is None: + desc += ["Open a pack of 5 items. Each item pays its multiplier ร— your bet. Collect them all!"] + e.description = "\n".join(desc) + + if self.last_pack is not None: + lines = [] + for it in self.last_pack: + tag = "NEW" if it.get("_new") else "DUP" + lines.append(f"{it['emoji']} **{it['name']}** ยท *{it['rarity']}* ยท ร—{it['mult']:.2f} `{tag}`") + e.add_field(name="Last Pack", value="\n".join(lines), inline=False) + e.add_field( + name="Result", + value=f"Return: **{money(self.last_return)}** โ€ข Net: **{'+' if self.last_net>0 else ''}{money(self.last_net)}**", + inline=False + ) + return e + + # ---- actions + async def _open_pack(self, itx: discord.Interaction): + if self.busy: return + self.busy = True + + cash, _ = db.get_wallet(self.user_id) + if self.bet > cash: + self.busy = False + return await itx.response.send_message("Not enough cash for that bet.", ephemeral=True) + + # debit + db.add_cash(self.user_id, -self.bet) + + # current collection to mark NEW/DUP + coll = db.get_packs_collection(self.user_id) # dict card_id -> qty + + pulled: List[Dict] = [] + total_mult = 0.0 + for _ in range(PACK_SIZE): + it = pick_one().copy() + it["_new"] = (coll.get(it["id"], 0) == 0) + pulled.append(it) + total_mult += it["mult"] + # persist collection increment + db.add_pack_card(self.user_id, it["id"], 1) + coll[it["id"]] = coll.get(it["id"], 0) + 1 + + returned = int(self.bet * total_mult) + if returned > 0: + db.add_cash(self.user_id, returned) + + db.record_packs_open(self.user_id, bet=self.bet, return_amount=returned, won=(returned > self.bet)) + + self.last_pack = pulled + self.last_return = returned + self.last_net = returned - self.bet + + await itx.response.edit_message(embed=self.render(), view=self) + self.busy = False + + # ----- Buttons + @discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_bet(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + await itx.response.send_modal(SetBetModal(self)) + + @discord.ui.button(label="ร—2", style=discord.ButtonStyle.secondary, row=0) + async def x2(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + self.bet = max(PACK_MIN_BET, self.bet * 2) + await itx.response.edit_message(embed=self.render(), view=self) + + @discord.ui.button(label="ยฝ", style=discord.ButtonStyle.secondary, row=0) + async def half(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + self.bet = max(PACK_MIN_BET, self.bet // 2) + await itx.response.edit_message(embed=self.render(), view=self) + + @discord.ui.button(label="Open Pack", style=discord.ButtonStyle.success, emoji="๐ŸŽ", row=0) + async def open_pack(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + await self._open_pack(itx) + + @discord.ui.button(label="Open Again", style=discord.ButtonStyle.primary, emoji="๐Ÿ”", row=1) + async def open_again(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + await self._open_pack(itx) + + @discord.ui.button(label="Collection", style=discord.ButtonStyle.secondary, emoji="๐Ÿ—‚๏ธ", row=1) + async def collection(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: + return await itx.response.send_message("This isnโ€™t your Packs panel.", ephemeral=True) + view = PacksCollectionView(self.user_id, start_page=0, timeout=180) + await itx.response.send_message(embed=view.embed(), view=view, ephemeral=True) + +# ---------- Cog ---------- +class PacksCog(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="packs") + async def packs(self, ctx: commands.Context, bet: int = None): + uid = ctx.author.id + view = PacksView(uid, initial_bet=bet or PACK_MIN_BET, timeout=180) + msg = await ctx.send(embed=view.render(), view=view) + view.message = msg + + @commands.command(name="collection", aliases=["packdex","packsdex"]) + async def collection(self, ctx: commands.Context): + uid = ctx.author.id + view = PacksCollectionView(uid, start_page=0, timeout=180) + msg = await ctx.send(embed=view.embed(), view=view) + view.message = msg + +async def setup(bot): + # make sure DB tables exist for packs + db.ensure_packs_tables() + await bot.add_cog(PacksCog(bot)) diff --git a/src/db.py b/src/db.py index 18312bb..fae6697 100644 --- a/src/db.py +++ b/src/db.py @@ -6,11 +6,16 @@ DB_PATH = os.getenv("DB_PATH", "/app/data/blackjack.db") def _connect(): return sqlite3.connect(DB_PATH) +# --------------------------- bootstrap / migrations --------------------------- + def init_db(): + """ + Creates base tables and safely migrates new columns over time. + """ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) - conn = _connect() - cur = conn.cursor() - # base + conn = _connect(); cur = conn.cursor() + + # Base users table cur.execute(""" CREATE TABLE IF NOT EXISTS users ( user_id INTEGER PRIMARY KEY, @@ -20,43 +25,86 @@ def init_db(): last_daily DATE DEFAULT NULL ) """) - # add new columns if missing + + # Existing columns cur.execute("PRAGMA table_info(users)") cols = {row[1] for row in cur.fetchall()} - add_cols = [] - # shared / economy - if "free_spins" not in cols: add_cols.append(("free_spins", "INTEGER DEFAULT 0")) - if "last_topup" not in cols: add_cols.append(("last_topup", "TEXT DEFAULT NULL")) - if "last_daily_ts" not in cols:add_cols.append(("last_daily_ts", "TEXT DEFAULT NULL")) - # blackjack - if "games_played_blackjack" not in cols: add_cols.append(("games_played_blackjack", "INTEGER DEFAULT 0")) - if "games_won_blackjack" not in cols: add_cols.append(("games_won_blackjack", "INTEGER DEFAULT 0")) - # slots - if "games_played_slots" not in cols: add_cols.append(("games_played_slots", "INTEGER DEFAULT 0")) - if "games_won_slots" not in cols: add_cols.append(("games_won_slots", "INTEGER DEFAULT 0")) - if "slots_biggest_win" not in cols: add_cols.append(("slots_biggest_win", "INTEGER DEFAULT 0")) - # roulette - if "games_played_roulette" not in cols: add_cols.append(("games_played_roulette", "INTEGER DEFAULT 0")) - if "roulette_biggest_win" not in cols: add_cols.append(("roulette_biggest_win", "INTEGER DEFAULT 0")) - if "roulette_net" not in cols: add_cols.append(("roulette_net", "INTEGER DEFAULT 0")) - # coin flip - if "games_played_coinflip" not in cols: add_cols.append(("games_played_coinflip", "INTEGER DEFAULT 0")) - if "games_won_coinflip" not in cols: add_cols.append(("games_won_coinflip", "INTEGER DEFAULT 0")) - if "coinflip_biggest_win" not in cols: add_cols.append(("coinflip_biggest_win", "INTEGER DEFAULT 0")) - if "coinflip_net" not in cols: add_cols.append(("coinflip_net", "INTEGER DEFAULT 0")) - # towers - if "games_played_towers" not in cols: add_cols.append(("games_played_towers", "INTEGER DEFAULT 0")) - if "games_won_towers" not in cols: add_cols.append(("games_won_towers", "INTEGER DEFAULT 0")) - # baccarat (NEW) - if "games_played_baccarat" not in cols: add_cols.append(("games_played_baccarat", "INTEGER DEFAULT 0")) - if "games_won_baccarat" not in cols: add_cols.append(("games_won_baccarat", "INTEGER DEFAULT 0")) - if "baccarat_net" not in cols: add_cols.append(("baccarat_net", "INTEGER DEFAULT 0")) - if "baccarat_biggest_win" not in cols: add_cols.append(("baccarat_biggest_win", "INTEGER DEFAULT 0")) - for name, decl in add_cols: + add: list[tuple[str, str]] = [] + def need(name: str, ddl: str): + """Queue a column ADD if missing.""" + if name not in cols: + add.append((name, ddl)) + + # Shared + need("free_spins", "INTEGER DEFAULT 0") + need("last_topup", "TEXT DEFAULT NULL") + need("last_daily_ts", "TEXT DEFAULT NULL") + + # Blackjack + need("games_played_blackjack", "INTEGER DEFAULT 0") + need("games_won_blackjack", "INTEGER DEFAULT 0") + + # Slots + need("games_played_slots", "INTEGER DEFAULT 0") + need("games_won_slots", "INTEGER DEFAULT 0") + need("slots_biggest_win", "INTEGER DEFAULT 0") + + # Roulette + need("games_played_roulette", "INTEGER DEFAULT 0") + need("roulette_biggest_win", "INTEGER DEFAULT 0") + need("roulette_net", "INTEGER DEFAULT 0") + + # Coin Flip + need("games_played_coinflip", "INTEGER DEFAULT 0") + need("games_won_coinflip", "INTEGER DEFAULT 0") + need("coinflip_biggest_win", "INTEGER DEFAULT 0") + need("coinflip_net", "INTEGER DEFAULT 0") + + # Towers + need("games_played_towers", "INTEGER DEFAULT 0") + need("games_won_towers", "INTEGER DEFAULT 0") + + # Baccarat + need("games_played_baccarat", "INTEGER DEFAULT 0") + need("games_won_baccarat", "INTEGER DEFAULT 0") + need("baccarat_net", "INTEGER DEFAULT 0") + need("baccarat_biggest_win", "INTEGER DEFAULT 0") + + # Hi-Lo + need("games_played_hilo", "INTEGER DEFAULT 0") + need("games_won_hilo", "INTEGER DEFAULT 0") + need("hilo_net", "INTEGER DEFAULT 0") + need("hilo_biggest_win", "INTEGER DEFAULT 0") + + # Mines + need("games_played_mines", "INTEGER DEFAULT 0") + need("games_won_mines", "INTEGER DEFAULT 0") + need("mines_net", "INTEGER DEFAULT 0") + need("mines_biggest_win", "INTEGER DEFAULT 0") + + # Packs + need("games_played_packs", "INTEGER DEFAULT 0") + need("games_won_packs", "INTEGER DEFAULT 0") + need("packs_net", "INTEGER DEFAULT 0") + need("packs_biggest_win", "INTEGER DEFAULT 0") + + # Apply queued column ADDs + for name, decl in add: cur.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}") - conn.commit() - conn.close() + + # Collections table for Packs + cur.execute(""" + CREATE TABLE IF NOT EXISTS packs_user_cards ( + user_id INTEGER NOT NULL, + card_id TEXT NOT NULL, + qty INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, card_id) + )""") + + conn.commit(); conn.close() + +# ------------------------------ user & wallet -------------------------------- def ensure_user(user_id: int): conn = _connect(); cur = conn.cursor() @@ -96,10 +144,10 @@ def consume_free_spin(user_id: int) -> bool: cur.execute("UPDATE users SET free_spins = free_spins - 1 WHERE user_id=?", (user_id,)) conn.commit(); conn.close() return True - conn.close() - return False + conn.close(); return False + +# ----------------------------- generic counters ------------------------------ -# legacy aggregate def record_game(user_id: int, won: bool): ensure_user(user_id) conn = _connect(); cur = conn.cursor() @@ -109,7 +157,8 @@ def record_game(user_id: int, won: bool): cur.execute("UPDATE users SET games_played=games_played+1 WHERE user_id=?", (user_id,)) conn.commit(); conn.close() -# per-game +# ------------------------------ per-game stats ------------------------------- + def record_blackjack(user_id: int, won: bool): ensure_user(user_id) conn = _connect(); cur = conn.cursor() @@ -187,7 +236,6 @@ def record_coinflip(user_id: int, bet: int, return_amount: int, won: bool): record_game(user_id, won) def record_towers(user_id: int, won: bool): - """Call with won=True on cashout/full clear; won=False on bomb.""" ensure_user(user_id) conn = _connect(); cur = conn.cursor() if won: @@ -206,13 +254,7 @@ def record_towers(user_id: int, won: bool): conn.commit(); conn.close() record_game(user_id, won) -# NEW: Baccarat def record_baccarat(user_id: int, total_bet: int, total_return: int): - """ - Record a single baccarat hand. - - total_bet: sum of Player+Banker+Tie stakes debited - - total_return: total chips returned (including stake(s) on pushes/wins) - """ ensure_user(user_id) net = total_return - total_bet won = net > 0 @@ -228,45 +270,178 @@ def record_baccarat(user_id: int, total_bet: int, total_return: int): conn.commit(); conn.close() record_game(user_id, won) +def record_hilo(user_id: int, bet: int, return_amount: int, won: bool): + """ + Hi-Lo round: + - bet debited up front + - return_amount received on cashout (0 on loss) + - won for W/L if return > bet + """ + ensure_user(user_id) + net = return_amount - bet + conn = _connect(); cur = conn.cursor() + if won: + cur.execute(""" + UPDATE users + SET games_played_hilo = games_played_hilo + 1, + games_won_hilo = games_won_hilo + 1, + hilo_net = hilo_net + ?, + hilo_biggest_win = CASE WHEN ? > hilo_biggest_win THEN ? ELSE hilo_biggest_win END + WHERE user_id = ? + """, (net, return_amount, return_amount, user_id)) + else: + cur.execute(""" + UPDATE users + SET games_played_hilo = games_played_hilo + 1, + hilo_net = hilo_net + ? + WHERE user_id = ? + """, (net, user_id)) + conn.commit(); conn.close() + record_game(user_id, won) + +def record_mines(user_id: int, bet: int, return_amount: int, won: bool): + ensure_user(user_id) + net = return_amount - bet + conn = _connect(); cur = conn.cursor() + if won: + cur.execute(""" + UPDATE users + SET games_played_mines = games_played_mines + 1, + games_won_mines = games_won_mines + 1, + mines_net = mines_net + ?, + mines_biggest_win = CASE WHEN ? > mines_biggest_win THEN ? ELSE mines_biggest_win END + WHERE user_id = ? + """, (net, return_amount, return_amount, user_id)) + else: + cur.execute(""" + UPDATE users + SET games_played_mines = games_played_mines + 1, + mines_net = mines_net + ? + WHERE user_id = ? + """, (net, user_id)) + conn.commit(); conn.close() + record_game(user_id, won) + +# ---------------------------------- Packs ------------------------------------ + +def ensure_packs_tables(): + conn = _connect(); cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS packs_user_cards ( + user_id INTEGER NOT NULL, + card_id TEXT NOT NULL, + qty INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, card_id) + )""") + conn.commit(); conn.close() + +def add_pack_card(user_id: int, card_id: str, qty: int = 1): + ensure_user(user_id) + ensure_packs_tables() + conn = _connect(); cur = conn.cursor() + cur.execute(""" + INSERT INTO packs_user_cards (user_id, card_id, qty) + VALUES (?, ?, ?) + ON CONFLICT(user_id, card_id) DO UPDATE SET qty = qty + excluded.qty + """, (user_id, card_id, qty)) + conn.commit(); conn.close() + +def get_packs_collection(user_id: int) -> dict: + ensure_user(user_id) + ensure_packs_tables() + conn = _connect(); cur = conn.cursor() + cur.execute("SELECT card_id, qty FROM packs_user_cards WHERE user_id = ?", (user_id,)) + out = {row[0]: row[1] for row in cur.fetchall()} + conn.close() + return out + +def record_packs_open(user_id: int, bet: int, return_amount: int, won: bool): + ensure_user(user_id) + net = return_amount - bet + conn = _connect(); cur = conn.cursor() + if won: + cur.execute(""" + UPDATE users + SET games_played_packs = games_played_packs + 1, + games_won_packs = games_won_packs + 1, + packs_net = packs_net + ?, + packs_biggest_win = CASE WHEN ? > packs_biggest_win THEN ? ELSE packs_biggest_win END + WHERE user_id = ? + """, (net, return_amount, return_amount, user_id)) + else: + cur.execute(""" + UPDATE users + SET games_played_packs = games_played_packs + 1, + packs_net = packs_net + ? + WHERE user_id = ? + """, (net, user_id)) + conn.commit(); conn.close() + record_game(user_id, won) + +# ------------------------------ queries & misc ------------------------------- + def top_cash(limit=10): conn = _connect(); cur = conn.cursor() cur.execute("SELECT user_id, cash FROM users ORDER BY cash DESC LIMIT ?", (limit,)) - rows = cur.fetchall() - conn.close() + rows = cur.fetchall(); conn.close() return rows def user_counts(): conn = _connect(); cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM users") - n = cur.fetchone()[0] - conn.close() - return n + cur.execute("SELECT COUNT(*) FROM users"); n = cur.fetchone()[0] + conn.close(); return n def get_full_stats(user_id: int): ensure_user(user_id) conn = _connect(); cur = conn.cursor() cur.execute(""" SELECT cash, free_spins, games_played, games_won, + games_played_blackjack, games_won_blackjack, + games_played_slots, games_won_slots, + games_played_roulette, roulette_net, + games_played_coinflip, games_won_coinflip, coinflip_net, + games_played_towers, games_won_towers, - games_played_baccarat, games_won_baccarat, baccarat_net + + games_played_baccarat, games_won_baccarat, baccarat_net, + + games_played_hilo, games_won_hilo, hilo_net, + + games_played_mines, games_won_mines, mines_net, + + games_played_packs, games_won_packs, packs_net FROM users WHERE user_id=? """, (user_id,)) row = cur.fetchone(); conn.close() keys = [ "cash","free_spins","games_played","games_won", + "gp_bj","gw_bj", + "gp_slots","gw_slots", + "gp_roulette","roulette_net", + "gp_coin","gw_coin","coin_net", + "gp_towers","gw_towers", + "gp_bac","gw_bac","bac_net", + + "gp_hilo","gw_hilo","hilo_net", + + "gp_mines","gw_mines","mines_net", + + "gp_packs","gw_packs","packs_net", ] return dict(zip(keys, row)) +# -------------------------- faucet / daily / transfer ------------------------- + def can_claim_topup(user_id: int, cooldown_minutes: int = 5) -> tuple[bool, int]: ensure_user(user_id) conn = _connect(); cur = conn.cursor() @@ -276,8 +451,7 @@ def can_claim_topup(user_id: int, cooldown_minutes: int = 5) -> tuple[bool, int] if not last: return True, 0 try: last_dt = datetime.fromisoformat(last) except Exception: return True, 0 - now = datetime.utcnow() - wait = timedelta(minutes=cooldown_minutes) + now = datetime.utcnow(); wait = timedelta(minutes=cooldown_minutes) if now - last_dt >= wait: return True, 0 left = int((wait - (now - last_dt)).total_seconds()) return False, left @@ -298,16 +472,14 @@ def daily_cooldown(user_id: int, hours: int = 24) -> tuple[bool, int]: if not last: return True, 0 try: last_dt = datetime.fromisoformat(last) except Exception: return True, 0 - now = datetime.utcnow() - wait = timedelta(hours=hours) + now = datetime.utcnow(); wait = timedelta(hours=hours) if now - last_dt >= wait: return True, 0 secs_left = int((wait - (now - last_dt)).total_seconds()) return False, secs_left def claim_daily(user_id: int, cash_bonus: int, spin_bonus: int): ensure_user(user_id) - today = str(date.today()) - now_str = datetime.utcnow().isoformat(timespec="seconds") + today = str(date.today()); now_str = datetime.utcnow().isoformat(timespec="seconds") conn = _connect(); cur = conn.cursor() cur.execute(""" UPDATE users @@ -337,4 +509,3 @@ def transfer_cash(from_user_id: int, to_user_id: int, amount: int): conn.rollback(); raise finally: conn.close() - diff --git a/src/utils/constants.py b/src/utils/constants.py index 7ae9ccc..e599ab1 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -54,14 +54,25 @@ ROULETTE_PAYOUTS = { "lowhigh": 1, # K=9 (0 loses) } -# --- Coin Flip constants --- - +# Coin flip Tuning COINFLIP_MIN_CHIP = int(os.getenv("COINFLIP_MIN_CHIP", 10)) COINFLIP_MIN_BET = int(os.getenv("COINFLIP_MIN_BET", COINFLIP_MIN_CHIP)) COINFLIP_MAX_BET = int(os.getenv("COINFLIP_MAX_BET", 50000)) -# --- Towers constants --- +# Towers tuning TOWERS_MIN_BET = int(os.getenv("TOWERS_MIN_BET", 10)) TOWERS_MAX_BET = int(os.getenv("TOWERS_MAX_BET", 50000)) TOWERS_EDGE_PER_STEP = float(os.getenv("TOWERS_EDGE_PER_STEP", 0.02)) +# Hi-Lo tuning +HILO_EDGE_PER_STEP = 0.02 # 2% edge per correct step +HILO_MAX_MULT = 50.0 # cap total multiplier to avoid runaway +HILO_MIN_BET = 10 + +# Mines tuning +MINES_MIN_BET = 10 +MINES_EDGE_PER_STEP = 0.015 +MINES_MAX_MULT = 1000.0 +MINES_CHOICES = [1, 3, 5, 7] + +