From 45afb95d141c1499f5b3ff7c28c8faf446aef19c Mon Sep 17 00:00:00 2001 From: Adan Gandarilla Date: Thu, 28 Aug 2025 02:51:39 -0500 Subject: [PATCH] Initial commit: Casino bot --- .env.example | 12 + .gitignore | 14 + Dockerfile | 21 ++ README.md | 7 + docker-compose.yml | 22 ++ requirements.txt | 2 + src/__init__.py | 0 src/bot.py | 35 +++ src/cogs/__init__.py | 0 src/cogs/baccarat.py | 337 +++++++++++++++++++++++ src/cogs/blackjack.py | 439 ++++++++++++++++++++++++++++++ src/cogs/coinflip.py | 196 ++++++++++++++ src/cogs/economy.py | 325 +++++++++++++++++++++++ src/cogs/roulette.py | 590 +++++++++++++++++++++++++++++++++++++++++ src/cogs/slots.py | 340 ++++++++++++++++++++++++ src/cogs/towers.py | 372 ++++++++++++++++++++++++++ src/db.py | 340 ++++++++++++++++++++++++ src/utils/__init__.py | 0 src/utils/constants.py | 67 +++++ 19 files changed, 3119 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/bot.py create mode 100644 src/cogs/__init__.py create mode 100644 src/cogs/baccarat.py create mode 100644 src/cogs/blackjack.py create mode 100644 src/cogs/coinflip.py create mode 100644 src/cogs/economy.py create mode 100644 src/cogs/roulette.py create mode 100644 src/cogs/slots.py create mode 100644 src/cogs/towers.py create mode 100644 src/db.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/constants.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c36dfd4 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Discord Bot +DISCORD_TOKEN=YOUR_BOT_TOKEN_HERE + +# Database +DB_PATH=/app/data/blackjack.db + +# Economy tuning (optional) +DAILY_CASH=10000 +DAILY_FREE_SPINS=3 +TOPUP_AMOUNT=100 +TOPUP_COOLDOWN_MINUTES=5 +DAILY_COOLDOWN_HOURS=24 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fd4e7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.pyc +.venv/ +.env +.env.local +# OS +.DS_Store +# Project +data/ +*.db +backups/ +# Docker +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..931f985 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim +WORKDIR /app + +RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src ./src +RUN mkdir -p /app/data + +# non-root +RUN useradd -r -s /bin/false botuser && chown -R botuser:botuser /app +USER botuser + +# healthcheck: open/close DB +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import sqlite3; sqlite3.connect('/app/data/blackjack.db').close()" || exit 1 + +CMD ["python", "-m", "src.bot"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6bfbc7 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Blackjack, Slots, Mini Roulette, Coin Flip, Towers, Baccarat โ€” all-in-one Discord bot. + +## Quick start (Docker) +```bash +cp .env.example .env +# edit .env and paste your DISCORD_TOKEN +docker compose up -d --build diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f0fccef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + casino-bot: + build: . + container_name: discord-casino-bot + restart: unless-stopped + env_file: .env + user: "${UID:-1000}:${GID:-1000}" + environment: + - PYTHONUNBUFFERED=1 + - DB_PATH=/app/data/blackjack.db + volumes: + - ./data:/app/data + healthcheck: + test: ["CMD", "python", "-c", "import sqlite3; sqlite3.connect('/app/data/blackjack.db').close()"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + logging: + driver: "json-file" + options: { max-size: "10m", max-file: "3" } + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..55de418 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +discord.py==2.3.2 +aiohttp==3.9.1 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..be7833c --- /dev/null +++ b/src/bot.py @@ -0,0 +1,35 @@ +import os, asyncio, discord +from discord.ext import commands +from . import db + +intents = discord.Intents.default() +intents.message_content = True +intents.members = True # better name resolution + +BOT_PREFIX = os.getenv("BOT_PREFIX", "!") +TOKEN = os.getenv("DISCORD_TOKEN") + +bot = commands.Bot(command_prefix=BOT_PREFIX, intents=intents) + +@bot.event +async def on_ready(): + print(f"๐ŸŽฏ {bot.user} has connected to Discord!") + print(f"๐Ÿ”— Bot is in {len(bot.guilds)} servers") + +async def main(): + if not TOKEN: + print("โŒ DISCORD_TOKEN environment variable not found!"); raise SystemExit(1) + db.init_db() + # Load cogs + await bot.load_extension("src.cogs.economy") + await bot.load_extension("src.cogs.blackjack") + await bot.load_extension("src.cogs.slots") + await bot.load_extension("src.cogs.roulette") + await bot.load_extension("src.cogs.coinflip") + await bot.load_extension("src.cogs.towers") + await bot.load_extension("src.cogs.baccarat") + await bot.start(TOKEN) + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/src/cogs/__init__.py b/src/cogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cogs/baccarat.py b/src/cogs/baccarat.py new file mode 100644 index 0000000..cf97713 --- /dev/null +++ b/src/cogs/baccarat.py @@ -0,0 +1,337 @@ +# src/cogs/baccarat.py +import random, discord +from discord.ext import commands +from typing import Optional, List, Tuple, Dict + +from .. import db + +# ====== Config (inline so you don't have to touch constants.py) ====== +BAC_MIN_BET = 10 +BAC_MAX_BET = 100_000 +TIE_PAYS = 8 # 8:1 (plus stake => ร—9 return) +BANKER_COMMISSION = 0.05 # 5% + +# ====== Cards / shoe ====== +SUITS = ["โ™ ๏ธ","โ™ฅ๏ธ","โ™ฆ๏ธ","โ™ฃ๏ธ"] +RANKS = ["A","2","3","4","5","6","7","8","9","10","J","Q","K"] + +def baccarat_val(rank: str) -> int: + if rank == "A": return 1 + if rank in ("10","J","Q","K"): return 0 + return int(rank) + +def total(hand: List[Tuple[str,str]]) -> int: + return sum(baccarat_val(r) for _, r in hand) % 10 + +class Shoe: + """Simple 8-deck shoe; rebuild when low.""" + def __init__(self, decks: int = 8): + self.decks = decks + self.cards: List[Tuple[str,str]] = [] + self._build() + + def _build(self): + self.cards = [(s, r) for _ in range(self.decks) for s in SUITS for r in RANKS] + random.shuffle(self.cards) + + def draw(self) -> Tuple[str,str]: + if len(self.cards) < 12: + self._build() + return self.cards.pop() + +# ====== Third-card rules ====== +def deal_baccarat_round(shoe: Shoe): + """Return dict with hands, totals, winner, draws detail.""" + player: List[Tuple[str,str]] = [shoe.draw(), shoe.draw()] + banker: List[Tuple[str,str]] = [shoe.draw(), shoe.draw()] + + pt = total(player) + bt = total(banker) + natural = (pt >= 8 or bt >= 8) + + player_third = None + banker_third = None + + if not natural: + # Player rule + if pt <= 5: + player_third = shoe.draw() + player.append(player_third) + pt = total(player) + + # Banker rules depend on whether player drew + if player_third is None: + # Player stood + if bt <= 5: + banker_third = shoe.draw() + banker.append(banker_third) + bt = total(banker) + else: + # Player drew: use banker conditional table on player's third rank value + pv = baccarat_val(player_third[1]) + if bt <= 2: + banker_third = shoe.draw() + banker.append(banker_third) + bt = total(banker) + elif bt == 3 and pv != 8: + banker_third = shoe.draw(); banker.append(banker_third); bt = total(banker) + elif bt == 4 and pv in (2,3,4,5,6,7): + banker_third = shoe.draw(); banker.append(banker_third); bt = total(banker) + elif bt == 5 and pv in (4,5,6,7): + banker_third = shoe.draw(); banker.append(banker_third); bt = total(banker) + elif bt == 6 and pv in (6,7): + banker_third = shoe.draw(); banker.append(banker_third); bt = total(banker) + # bt==7 stands + + # Determine result + winner: str + if pt > bt: + winner = "PLAYER" + elif bt > pt: + winner = "BANKER" + else: + winner = "TIE" + + return { + "player": player, "banker": banker, + "pt": pt, "bt": bt, "winner": winner, + "player_third": player_third, "banker_third": banker_third, + } + +def fmt_hand(hand: List[Tuple[str,str]]) -> str: + return " ".join(f"{r}{s}" for s, r in hand) + +def money(n: int) -> str: + return f"${n:,}" + +# ====== UI ====== +_active_bac: Dict[int, "BaccaratView"] = {} + +class SetBetModal(discord.ui.Modal): + def __init__(self, view: "BaccaratView", label: str, target: str): + super().__init__(title=f"Set {label} Bet") + self.view = view + self.target = target # "player" | "tie" | "banker" + self.amount = discord.ui.TextInput( + label=f"Amount (min {BAC_MIN_BET}, max {BAC_MAX_BET})", + placeholder=str(getattr(view, f'bet_{target}')), + 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 baccarat panel.", ephemeral=True) + if self.view.in_round: + return await itx.response.send_message("Wait for the current deal to finish.", 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) + amt = max(BAC_MIN_BET, min(BAC_MAX_BET, amt)) + setattr(self.view, f"bet_{self.target}", amt) + + await itx.response.defer(ephemeral=True) + if self.view.message: + await self.view.message.edit(embed=self.view.panel_embed(), view=self.view) + +class BaccaratView(discord.ui.View): + """ + Row 0: Player Bet โ€ข Tie Bet โ€ข Banker Bet โ€ข Deal + """ + def __init__(self, user_id: int, *, timeout: int = 180): + super().__init__(timeout=timeout) + self.user_id = user_id + self.shoe = Shoe(8) + + self.bet_player = 0 + self.bet_tie = 0 + self.bet_banker = 0 + + self.in_round = False + self.last_outcome: Optional[str] = None + self.last_detail: Optional[str] = None + self.last_player: List[Tuple[str,str]] = [] + self.last_banker: List[Tuple[str,str]] = [] + self.last_pt = 0 + self.last_bt = 0 + + self.message: Optional[discord.Message] = None + + # ----- 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) + finally: + _active_bac.pop(self.user_id, None) + + # ----- render ----- + def panel_embed(self) -> discord.Embed: + cash, _ = db.get_wallet(self.user_id) + e = discord.Embed(title="๐Ÿƒ  Baccarat", color=discord.Color.dark_green()) + lines = [ + f"**Bets:** Player {money(self.bet_player)} โ€ข Tie {money(self.bet_tie)} โ€ข Banker {money(self.bet_banker)}", + f"**Balance:** {money(cash)}", + "", + ] + if self.last_outcome: + board = ( + f"**Player** ({self.last_pt}) โ€” {fmt_hand(self.last_player)}\n" + f"**Banker** ({self.last_bt}) โ€” {fmt_hand(self.last_banker)}\n" + f"**Result:** **{self.last_outcome}**" + ) + lines.append(board) + if self.last_detail: + lines.append(self.last_detail) + + e.description = "\n".join(lines) + e.set_footer(text="Set your bets, then Deal. Player pays 1:1 โ€ข Banker 1:1 (5% commission) โ€ข Tie 8:1 (Player/Banker push on tie)") + return e + + # ----- helpers ----- + def total_stake(self) -> int: + return max(0, self.bet_player) + max(0, self.bet_tie) + max(0, self.bet_banker) + + async def _ensure_owner(self, itx: discord.Interaction): + if itx.user.id != self.user_id: + raise await itx.response.send_message("This isnโ€™t your baccarat panel.", ephemeral=True) + + async def _deal_once(self, itx: discord.Interaction): + # Validate stake + total = self.total_stake() + if total <= 0: + return await itx.response.send_message("Place a bet first (Player, Tie, or Banker).", ephemeral=True) + cash, _ = db.get_wallet(self.user_id) + if cash < total: + return await itx.response.send_message(f"Not enough cash for total stake {money(total)}.", ephemeral=True) + + # Debit upfront + db.add_cash(self.user_id, -total) + self.in_round = True + await itx.response.edit_message(embed=self.panel_embed(), view=self) + + # Deal & settle + res = deal_baccarat_round(self.shoe) + self.last_player = res["player"] + self.last_banker = res["banker"] + self.last_pt = res["pt"] + self.last_bt = res["bt"] + winner = res["winner"] + self.last_outcome = winner.title() + + # Compute returns + returned = 0 + won_any = False + + if winner == "PLAYER": + if self.bet_player > 0: + returned += self.bet_player * 2 # stake + 1:1 + won_any = True + # Banker bet loses; Tie loses + elif winner == "BANKER": + if self.bet_banker > 0: + profit = int(round(self.bet_banker * (1.0 - BANKER_COMMISSION))) + returned += self.bet_banker + profit # stake + profit (0.95x) + won_any = True + else: # TIE + if self.bet_tie > 0: + returned += self.bet_tie * (TIE_PAYS + 1) # stake + 8x + won_any = True + # Player/Banker push on tie + returned += self.bet_player + self.bet_banker + + if returned: + db.add_cash(self.user_id, returned) + + net = returned - total + if net > 0: + self.last_detail = f"๐Ÿ’ฐ Returned **{money(returned)}** โ€ข Net **+{money(net)}**" + elif net < 0: + self.last_detail = f"๐Ÿ’ธ Returned **{money(returned)}** โ€ข Net **-{money(-net)}**" + else: + self.last_detail = f"โ†”๏ธ Returned **{money(returned)}** โ€ข **Push**" + + # Record overall game W/L based on net + try: + db.record_baccarat(self.user_id, total_bet=total, total_return=returned) + except Exception: + pass + + # Round finished + self.in_round = False + + # Re-render + if self.message: + await self.message.edit(embed=self.panel_embed(), view=self) + + # ----- controls (Row 0) ----- + @discord.ui.button(label="Player Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_p(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if self.in_round: return await itx.response.send_message("Wait for the current deal to finish.", ephemeral=True) + await itx.response.send_modal(SetBetModal(self, "Player", "player")) + + @discord.ui.button(label="Tie Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_t(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if self.in_round: return await itx.response.send_message("Wait for the current deal to finish.", ephemeral=True) + await itx.response.send_modal(SetBetModal(self, "Tie", "tie")) + + @discord.ui.button(label="Banker Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_b(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if self.in_round: return await itx.response.send_message("Wait for the current deal to finish.", ephemeral=True) + await itx.response.send_modal(SetBetModal(self, "Banker", "banker")) + + @discord.ui.button(label="Deal", style=discord.ButtonStyle.success, emoji="๐Ÿƒ", row=0) + async def deal(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if self.in_round: return await itx.response.send_message("Already dealingโ€ฆ", ephemeral=True) + await self._deal_on_click(itx) + + async def _deal_on_click(self, itx: discord.Interaction): + try: + await self._deal_once(itx) + except Exception as e: + await itx.followup.send(f"โš ๏ธ Error during deal: `{type(e).__name__}: {e}`", ephemeral=True) + +class Baccarat(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="baccarat", aliases=["bac"]) + async def baccarat(self, ctx: commands.Context): + uid = ctx.author.id + if uid in _active_bac: + # Replace their old panel if it's still around + try: + old = _active_bac[uid] + if old.message: await old.message.delete() + except Exception: pass + _active_bac.pop(uid, None) + + view = BaccaratView(uid, timeout=180) + msg = await ctx.send(embed=view.panel_embed(), view=view) + view.message = msg + _active_bac[uid] = view + + @commands.command(name="rules_baccarat", aliases=["baccarat_rules"]) + async def rules_baccarat(self, ctx: commands.Context): + e = discord.Embed(title="๐Ÿƒ  Baccarat โ€” Rules", color=discord.Color.dark_green()) + e.description = ( + "**Goal:** Bet on which hand totals closest to 9 (mod 10). Aces=1, 10/J/Q/K=0, others=face value.\n\n" + "**Bets:** **Player** (1:1), **Banker** (1:1 less 5% commission), **Tie** (8:1). " + "On **Tie**, Player/Banker bets **push**.\n\n" + "**Drawing:** Naturals (8 or 9) stand. Otherwise Player may draw on 0โ€“5; Banker draws per standard third-card rules." + ) + await ctx.send(embed=e) + +async def setup(bot): + await bot.add_cog(Baccarat(bot)) + diff --git a/src/cogs/blackjack.py b/src/cogs/blackjack.py new file mode 100644 index 0000000..843e5b8 --- /dev/null +++ b/src/cogs/blackjack.py @@ -0,0 +1,439 @@ +import random, discord +from discord.ext import commands +from typing import Optional + +from .. import db + +# ====== Core game model (same logic you had) ====== +SUITS = ['โ™ ๏ธ', 'โ™ฅ๏ธ', 'โ™ฆ๏ธ', 'โ™ฃ๏ธ'] +RANKS = ['A','2','3','4','5','6','7','8','9','10','J','Q','K'] +CARD_VALUES = {'A':11,'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8,'9':9,'10':10,'J':10,'Q':10,'K':10} + +class Card: + def __init__(self, suit, rank): self.suit, self.rank = suit, rank; self.value = CARD_VALUES[rank] + def __str__(self): return f"{self.rank}{self.suit}" + +class Deck: + def __init__(self): + self.cards = [Card(s, r) for s in SUITS for r in RANKS] + random.shuffle(self.cards) + def draw(self): return self.cards.pop() if self.cards else None + +class Hand: + def __init__(self): self.cards = [] + def add_card(self, c): self.cards.append(c) + def get_value(self): + v = sum(c.value for c in self.cards); aces = sum(1 for c in self.cards if c.rank == 'A') + while v > 21 and aces: v -= 10; aces -= 1 + return v + def is_bust(self): return self.get_value() > 21 + def is_blackjack(self): return len(self.cards) == 2 and self.get_value() == 21 + def __str__(self): return ' '.join(str(c) for c in self.cards) + +active_games = {} # uid -> BlackjackGame + +class BlackjackGame: + def __init__(self, user_id: int, bet: int): + self.user_id = user_id; self.bet = bet; self.original_bet = bet + self.deck = Deck(); self.player_hand = Hand(); self.dealer_hand = Hand() + self.game_over = False; self.player_won = None; self.doubled_down = False + self.can_split = False; self.split_hands = []; self.current_split_hand = 0 + self.is_split_game = False; self.split_results = [] + for _ in range(2): + self.player_hand.add_card(self.deck.draw()); self.dealer_hand.add_card(self.deck.draw()) + self.check_can_split() + + def check_can_split(self): + if len(self.player_hand.cards) == 2: + a,b = self.player_hand.cards + if a.rank == b.rank or (a.value == 10 and b.value == 10): self.can_split = True + + def can_double_down(self): return len(self.player_hand.cards) == 2 and not self.doubled_down and not self.is_split_game + def get_current_hand(self): return self.split_hands[self.current_split_hand] if self.is_split_game else self.player_hand + + def hit_player(self): + h = self.get_current_hand(); h.add_card(self.deck.draw()) + if h.is_bust(): + if self.is_split_game: + self.split_results.append(False) + if not self.next_split_hand(): self.dealer_turn() + else: + self.game_over = True; self.player_won = False + + def next_split_hand(self): + if self.is_split_game and self.current_split_hand < len(self.split_hands) - 1: + self.current_split_hand += 1; return True + return False + + def double_down(self): + self.bet *= 2; self.doubled_down = True + self.hit_player() + if not self.player_hand.is_bust(): self.dealer_turn() + + def split_hand(self): + if not self.can_split: return False + a,b = self.player_hand.cards + h1, h2 = Hand(), Hand() + h1.add_card(a); h1.add_card(self.deck.draw()) + h2.add_card(b); h2.add_card(self.deck.draw()) + self.split_hands = [h1,h2]; self.is_split_game = True; self.current_split_hand = 0 + self.can_split = False; self.bet = self.original_bet + return True + + def dealer_turn(self): + while self.dealer_hand.get_value() < 17: self.dealer_hand.add_card(self.deck.draw()) + dv = self.dealer_hand.get_value() + if self.is_split_game: + for i,h in enumerate(self.split_hands): + if i < len(self.split_results): continue + pv = h.get_value() + if dv > 21 or pv > dv: self.split_results.append(True) + elif pv == dv: self.split_results.append(None) + else: self.split_results.append(False) + else: + pv = self.player_hand.get_value() + self.player_won = True if dv > 21 or pv > dv else (None if pv == dv else False) + self.game_over = True + +def create_embed(game: BlackjackGame, reveal: bool = False) -> discord.Embed: + # (unchanged renderer from your file) + dv = game.dealer_hand.get_value() + if game.game_over: + if game.is_split_game: + w = sum(1 for r in game.split_results if r is True) + l = sum(1 for r in game.split_results if r is False) + color = discord.Color.green() if w > l else (discord.Color.red() if l > w else discord.Color.orange()) + else: + color = discord.Color.green() if game.player_won is True else (discord.Color.red() if game.player_won is False else discord.Color.orange()) + else: color = discord.Color.blue() + + embed = discord.Embed(title="๐ŸŽฒ Blackjack", color=color) + + # player + if game.is_split_game: + for i,h in enumerate(game.split_hands): + hv, hc = h.get_value(), str(h) + status = "" + if i < len(game.split_results) and game.split_results[i] is not None: + status = " - WON! โœ…" if game.split_results[i] is True else (" - LOST ๐Ÿ˜”" if game.split_results[i] is False else " - PUSH ๐Ÿค") + elif i == game.current_split_hand and not game.game_over: + status = " - **CURRENT**" + if h.is_blackjack(): txt = f"{hc}\n**Value: {hv} - BLACKJACK! โœจ**{status}" + elif h.is_bust(): txt = f"{hc}\n**Value: {hv} - BUST! ๐Ÿ’ฅ**{status}" + else: txt = f"{hc}\n**Value: {hv}**{status}" + embed.add_field(name=f"๐Ÿง‘ Your Hand #{i+1}", value=txt, inline=False) + else: + ch = game.get_current_hand(); pv, pc = ch.get_value(), str(ch) + if ch.is_blackjack(): txt = f"{pc}\n**Value: {pv} - BLACKJACK! โœจ**" + elif ch.is_bust(): txt = f"{pc}\n**Value: {pv} - BUST! ๐Ÿ’ฅ**" + else: txt = f"{pc}\n**Value: {pv}**" + embed.add_field(name="๐Ÿง‘ Your Hand", value=txt, inline=False) + + # dealer + if reveal or game.game_over: + dc = str(game.dealer_hand) + dt = f"{dc}\n**Value: {dv} - BUST! ๐Ÿ’ฅ**" if game.dealer_hand.is_bust() else f"{dc}\n**Value: {dv}**" + else: + vc = str(game.dealer_hand.cards[0]) + dt = f"{vc} ๐Ÿ‚ \n**Value: ? + {game.dealer_hand.cards[0].value}**" + embed.add_field(name="๐Ÿ  Dealer Hand", value=dt, inline=False) + + # bet + result + if game.is_split_game: + embed.add_field(name="๐Ÿ’ฐ Total Bet", value=f"${game.original_bet*2} (${game.original_bet} per hand)", inline=True) + elif game.doubled_down: + embed.add_field(name="๐Ÿ’ฐ Bet (DOUBLED)", value=f"${game.bet}", inline=True) + else: + embed.add_field(name="๐Ÿ’ฐ Bet", value=f"${game.bet}", inline=True) + + if game.game_over: + if game.is_split_game: + wins = sum(1 for r in game.split_results if r is True) + losses = sum(1 for r in game.split_results if r is False) + pushes = sum(1 for r in game.split_results if r is None) + net = sum((+game.original_bet if r is True else (-game.original_bet if r is False else 0)) for r in game.split_results) + txt = f"**Split Results: {wins} wins, {losses} losses, {pushes} pushes**\n" + txt += f"**Net {'gain' if net>0 else ('loss' if net<0 else 'change')}: ${abs(net)}{'! ๐ŸŽ‰' if net>0 else (' ๐Ÿ˜”' if net<0 else ' ๐Ÿค')}**" + else: + if game.player_won is True: + if game.player_hand.is_blackjack(): win = int(game.bet * 1.5); txt = f"**BLACKJACK! You win ${win}! ๐ŸŽ‰**" + else: txt = f"**You win ${game.bet}! ๐ŸŽ‰**" + (" (Doubled down!)" if game.doubled_down else "") + elif game.player_won is False: + txt = f"**You lose ${game.bet}! ๐Ÿ˜”**" + (" (Doubled down!)" if game.doubled_down else "") + else: txt = f"**Push! Bet returned. ๐Ÿค**" + embed.add_field(name="๐Ÿ“Š Result", value=txt, inline=False) + return embed + +# ====== New UI layer (Set Bet / ร—2 / ยฝ / Deal + in-game buttons) ====== + +MIN_BJ_BET = 10 # simple minimum + +class SetBetModal(discord.ui.Modal, title="Set Blackjack Bet"): + def __init__(self, view: "BlackjackPanel"): + super().__init__() + self.view = view + self.amount = discord.ui.TextInput( + label=f"Amount (min {MIN_BJ_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 blackjack panel.", ephemeral=True) + if self.view.game is not None and not self.view.game.game_over: + return await itx.response.send_message("You canโ€™t change bet during a hand.", 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(MIN_BJ_BET, amt) + await itx.response.defer(ephemeral=True) + if self.view.message: + await self.view.message.edit(embed=self.view.panel_embed(), view=self.view) + +class BlackjackPanel(discord.ui.View): + """ + One-message blackjack: + Row 0: Set Bet, ร—2, ยฝ, Deal (deal & bet controls disabled during a hand) + Row 1: Hit, Stand, Double, Split (enabled only while a hand is active) + """ + 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(MIN_BJ_BET, initial_bet) + self.game: Optional[BlackjackGame] = None + self.message: Optional[discord.Message] = None + self._busy = False + self._sync_buttons() + + # ----- utils ----- + def _sync_buttons(self): + # default: enable everything + for it in self.children: + it.disabled = False + + hand_active = self.game is not None and not self.game.game_over + + # Setup row: disable while hand active + for lbl in ("Set Bet","ร—2","ยฝ","Deal"): + b = self._btn(lbl) + if b: b.disabled = hand_active + + # In-hand row: enable only while hand active + for lbl in ("Hit","Stand","Double Down","Split"): + b = self._btn(lbl) + if b: b.disabled = not hand_active + + # More granular in-hand state + if hand_active: + h = self.game.get_current_hand() + b_hit = self._btn("Hit") + b_dd = self._btn("Double Down") + b_sp = self._btn("Split") + if b_hit and (h.is_bust() or h.get_value() == 21): + b_hit.disabled = True + if b_dd: + b_dd.disabled = not self.game.can_double_down() + if b_sp: + b_sp.disabled = not self.game.can_split + + def _btn(self, label): + for c in self.children: + if isinstance(c, discord.ui.Button) and c.label == label: + return c + return None + + def disable_all_items(self): + for it in self.children: it.disabled = True + + async def on_timeout(self): + self.disable_all_items() + try: + if self.message: + await self.message.edit(view=self) + except: pass + + # ----- rendering ----- + def panel_embed(self) -> discord.Embed: + cash, _ = db.get_wallet(self.user_id) + + # No hand yet โ†’ neutral panel + if self.game is None: + 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" + f"**Current bet:** ${self.bet}\n" + ) + e.add_field(name="Balance", value=f"${cash:,}", inline=True) + return e + + # Hand finished โ†’ show the real result embed (already green/red/orange) + if self.game.game_over: + e = create_embed(self.game, reveal=True) + e.set_footer(text=f"Balance: ${cash:,} โ€ข Set Bet then Deal to play again") + return e + + # In-progress hand โ†’ normal in-hand embed (blue) + return create_embed(self.game, reveal=False) + + # ----- Setup row (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 blackjack panel.", ephemeral=True) + if self.game is not None and not self.game.game_over: + return await itx.response.send_message("You canโ€™t change bet during a hand.", ephemeral=True) + await itx.response.send_modal(SetBetModal(self)) + + @discord.ui.button(label="ร—2", style=discord.ButtonStyle.secondary, row=0) + async def bet_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.game is not None and not self.game.game_over: + return await itx.response.send_message("You canโ€™t change bet during a hand.", ephemeral=True) + self.bet = max(MIN_BJ_BET, self.bet * 2) + await itx.response.edit_message(embed=self.panel_embed(), view=self) + + @discord.ui.button(label="ยฝ", style=discord.ButtonStyle.secondary, row=0) + async def bet_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.game is not None and not self.game.game_over: + return await itx.response.send_message("You canโ€™t change bet during a hand.", ephemeral=True) + self.bet = max(MIN_BJ_BET, self.bet // 2) + await itx.response.edit_message(embed=self.panel_embed(), view=self) + + @discord.ui.button(label="Deal", style=discord.ButtonStyle.success, emoji="๐Ÿƒ", row=0) + async def deal(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True) + if self.game is not None and not self.game.game_over: + return await itx.response.send_message("Finish the current hand first.", ephemeral=True) + if self.user_id in active_games: + return await itx.response.send_message("You already have an active blackjack hand.", ephemeral=True) + + # validate funds similar to old command + cash,_ = db.get_wallet(self.user_id) + if self.bet < MIN_BJ_BET: + return await itx.response.send_message(f"Minimum bet is ${MIN_BJ_BET}.", ephemeral=True) + max_bet = cash//2 if self.bet*2 <= cash else cash + if self.bet > max_bet and self.bet != cash: + return await itx.response.send_message(f"Bet too high for doubling/splitting. Max recommended: ${max_bet}", ephemeral=True) + + # start hand + game = BlackjackGame(self.user_id, self.bet) + self.game = game; active_games[self.user_id] = game + # natural blackjack resolution + if game.player_hand.is_blackjack(): + if game.dealer_hand.is_blackjack(): + # push + self._sync_buttons() + return await itx.response.edit_message(embed=create_embed(game, reveal=True), view=self) + else: + game.game_over=True; game.player_won=True + db.add_cash(self.user_id, int(self.bet*1.5)); db.record_blackjack(self.user_id, True) + active_games.pop(self.user_id, None) + self._sync_buttons() + return await itx.response.edit_message(embed=create_embed(game, reveal=True), view=self) + + # regular start + self._sync_buttons() + await itx.response.edit_message(embed=self.panel_embed(), view=self) + + # ----- In-hand row (row 1) ----- + @discord.ui.button(label="Hit", style=discord.ButtonStyle.primary, row=1) + async def hit(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your hand.", ephemeral=True) + if self.game is None or self.game.game_over: return + self.game.hit_player() + if self.game.game_over: + # bust + db.add_cash(self.user_id, -self.game.bet); db.record_blackjack(self.user_id, False) + active_games.pop(self.user_id, None) + self._sync_buttons() + await itx.response.edit_message(embed=self.panel_embed(), view=self) + + @discord.ui.button(label="Stand", style=discord.ButtonStyle.secondary, row=1) + async def stand(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your hand.", ephemeral=True) + if self.game is None or self.game.game_over: return + if self.game.is_split_game: + if not self.game.next_split_hand(): + self.game.dealer_turn() + # else fall through to render below + else: + self.game.dealer_turn() + + if self.game.game_over: + # settle + if self.game.is_split_game: + net = 0; wins = 0 + for r in self.game.split_results: + if r is True: net += self.game.original_bet; wins += 1 + elif r is False: net -= self.game.original_bet + if net: db.add_cash(self.user_id, net) + for i in range(2): db.record_blackjack(self.user_id, i < wins) + else: + if self.game.player_won is True: + if self.game.player_hand.is_blackjack(): db.add_cash(self.user_id, int(self.game.bet*1.5)) + else: db.add_cash(self.user_id, self.game.bet) + elif self.game.player_won is False: + db.add_cash(self.user_id, -self.game.bet) + db.record_blackjack(self.user_id, self.game.player_won is True) + active_games.pop(self.user_id, None) + + self._sync_buttons() + await itx.response.edit_message(embed=self.panel_embed(), view=self) + + @discord.ui.button(label="Double Down", style=discord.ButtonStyle.success, row=1) + async def double_down(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your hand.", ephemeral=True) + if self.game is None or self.game.game_over: return + if not self.game.can_double_down(): + return await itx.response.send_message("โŒ Cannot double down now!", ephemeral=True) + cash,_ = db.get_wallet(self.user_id) + if cash < self.game.bet: + return await itx.response.send_message(f"โŒ Not enough cash to double down! Need ${self.game.bet} more.", ephemeral=True) + self.game.double_down() + if self.game.player_won is True: db.add_cash(self.user_id, self.game.bet) + elif self.game.player_won is False: db.add_cash(self.user_id, -self.game.bet) + db.record_blackjack(self.user_id, self.game.player_won is True) + active_games.pop(self.user_id, None) + self._sync_buttons() + await itx.response.edit_message(embed=self.panel_embed(), view=self) + + @discord.ui.button(label="Split", style=discord.ButtonStyle.danger, row=1) + async def split(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: return await itx.response.send_message("Not your hand.", ephemeral=True) + if self.game is None or self.game.game_over: return + if not self.game.can_split: + return await itx.response.send_message("โŒ Cannot split this hand!", ephemeral=True) + cash,_ = db.get_wallet(self.user_id) + if cash < self.game.bet: + return await itx.response.send_message(f"โŒ Not enough cash to split! Need ${self.game.bet} more.", ephemeral=True) + self.game.split_hand() + self._sync_buttons() + await itx.response.edit_message(embed=self.panel_embed(), view=self) + +class BlackjackCog(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="blackjack", aliases=["bj"]) + async def blackjack(self, ctx, bet: int = None): + """ + Open the Blackjack panel. + Tip: use the buttons to Set Bet / ร—2 / ยฝ, then Deal. + """ + uid = ctx.author.id + if uid in active_games: + return await ctx.send("โŒ You already have an active blackjack hand. Finish it first.") + + init_bet = 50 if bet is None else max(MIN_BJ_BET, bet) + view = BlackjackPanel(uid, initial_bet=init_bet, timeout=180) + msg = await ctx.send(embed=view.panel_embed(), view=view) + view.message = msg + +async def setup(bot): + await bot.add_cog(BlackjackCog(bot)) + diff --git a/src/cogs/coinflip.py b/src/cogs/coinflip.py new file mode 100644 index 0000000..03f1916 --- /dev/null +++ b/src/cogs/coinflip.py @@ -0,0 +1,196 @@ +# src/cogs/coinflip.py +# Coin Flip with Towers-style "Set Bet" modal and a minimal 2-row UI. +# Buttons: [Set Bet, ร—2, ยฝ] and [Heads, Tails] +# +# Commands: +# !coin (aliases: !coinflip, !cf, !flip) +# !rules_coin (optional) + +import random +import discord +from discord.ext import commands +from typing import Optional, Dict + +from .. import db +from ..utils.constants import ( + COINFLIP_MIN_CHIP, # minimum allowed chip value + COINFLIP_MIN_BET, # keep for limits consistency (we enforce MIN_CHIP) + COINFLIP_MAX_BET, # maximum per flip +) + +COIN_EMOJI = "๐Ÿช™" +HEADS_TXT = "Heads" +TAILS_TXT = "Tails" + +_active_coin_sessions: Dict[int, "CoinFlipView"] = {} + +def flip_coin() -> str: + return HEADS_TXT if random.random() < 0.5 else TAILS_TXT + + +class SetBetModal(discord.ui.Modal, title="Set Coin Flip Bet"): + def __init__(self, view: "CoinFlipView"): + super().__init__() + self.view = view + self.amount = discord.ui.TextInput( + label=f"Amount (min {COINFLIP_MIN_CHIP}, max {COINFLIP_MAX_BET})", + placeholder=str(self.view.chip or COINFLIP_MIN_CHIP), + 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 coin session!", 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) + + amt = max(COINFLIP_MIN_CHIP, min(COINFLIP_MAX_BET, amt)) + self.view.chip = amt + + await itx.response.defer(ephemeral=True) + if self.view.message: + await self.view.message.edit(embed=self.view.embed(), view=self.view) + + +class CoinFlipView(discord.ui.View): + def __init__(self, user_id: int, *, timeout: int = 120): + super().__init__(timeout=timeout) + self.user_id = user_id + self.chip = max(COINFLIP_MIN_CHIP, 100) + self.last_choice: Optional[str] = None + self.last_result: Optional[str] = None + self.message: Optional[discord.Message] = None + self._busy = False + + # ---------- lifecycle ---------- + def disable_all_items(self): + for item in self.children: + item.disabled = True + + async def on_timeout(self): + self.disable_all_items() + try: + if self.message: + await self.message.edit(view=self) + finally: + _active_coin_sessions.pop(self.user_id, None) + + # ---------- rendering ---------- + def embed(self) -> discord.Embed: + e = discord.Embed(title="๐Ÿช™ Coin Flip", color=discord.Color.blurple()) + if self.last_result is not None: + outcome = "โœ… WIN" if self.last_choice == self.last_result else "โŒ LOSS" + e.description = f"{COIN_EMOJI} **{self.last_result}** โ€ข You picked **{self.last_choice}** โ†’ {outcome}" + else: + e.description = "Pick **Heads** or **Tails**, set your **Bet**, and flip!" + cash, _ = db.get_wallet(self.user_id) + e.add_field(name="Bet per flip", value=f"${self.chip}", inline=True) + e.add_field(name="Balance", value=f"${cash:,}", inline=True) + e.set_footer(text="Flip as many times as you like. View times out after ~2 minutes.") + return e + + # ---------- guards ---------- + async def _ensure_owner(self, itx: discord.Interaction): + if itx.user.id != self.user_id: + raise await itx.response.send_message("This isnโ€™t your coin session!", ephemeral=True) + + # ---------- actions ---------- + async def _play(self, itx: discord.Interaction, choice: str): + await self._ensure_owner(itx) + if self._busy: + return await itx.response.send_message("Hang onโ€”resolving the last flipโ€ฆ", ephemeral=True) + if self.chip < COINFLIP_MIN_CHIP: + return await itx.response.send_message(f"Set a bet first (min ${COINFLIP_MIN_CHIP}).", ephemeral=True) + if self.chip > COINFLIP_MAX_BET: + return await itx.response.send_message(f"Max per-flip bet is ${COINFLIP_MAX_BET}.", ephemeral=True) + + cash, _ = db.get_wallet(self.user_id) + if cash < self.chip: + return await itx.response.send_message("Not enough cash for that flip.", ephemeral=True) + + self._busy = True + try: + # take the bet + db.add_cash(self.user_id, -self.chip) + + # flip & resolve + self.last_choice = choice + self.last_result = flip_coin() + won = (self.last_choice == self.last_result) + + return_amount = self.chip * 2 if won else 0 # 1:1 payout โ†’ return chip*2 on win + if return_amount: + db.add_cash(self.user_id, return_amount) + + # record stats (kept even if not shown on balance) + try: + db.record_coinflip(self.user_id, bet=self.chip, return_amount=return_amount, won=won) + except Exception: + pass + + await itx.response.edit_message(embed=self.embed(), view=self) + finally: + self._busy = False + + # ---------- controls ---------- + # Row 0 โ€” bet controls + @discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_bet(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + await itx.response.send_modal(SetBetModal(self)) + + @discord.ui.button(label="ร—2", style=discord.ButtonStyle.secondary, row=0) + async def bet_x2(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + self.chip = max(COINFLIP_MIN_CHIP, min(COINFLIP_MAX_BET, self.chip * 2)) + await itx.response.edit_message(embed=self.embed(), view=self) + + @discord.ui.button(label="ยฝ", style=discord.ButtonStyle.secondary, row=0) + async def bet_half(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + self.chip = max(COINFLIP_MIN_CHIP, self.chip // 2) + await itx.response.edit_message(embed=self.embed(), view=self) + + # Row 1 โ€” play buttons + @discord.ui.button(label="Heads", style=discord.ButtonStyle.success, emoji="๐Ÿ™‚", row=1) + async def pick_heads(self, itx: discord.Interaction, _): + await self._play(itx, HEADS_TXT) + + @discord.ui.button(label="Tails", style=discord.ButtonStyle.primary, emoji="๐ŸŒ€", row=1) + async def pick_tails(self, itx: discord.Interaction, _): + await self._play(itx, TAILS_TXT) + + +class CoinFlip(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="coin", aliases=["coinflip","cf","flip"]) + async def coin(self, ctx: commands.Context): + uid = ctx.author.id + if uid in _active_coin_sessions: + return await ctx.send("โŒ You already have an active coin session. Finish it or wait for it to time out.") + view = CoinFlipView(uid, timeout=120) + msg = await ctx.send(embed=view.embed(), view=view) + view.message = msg + _active_coin_sessions[uid] = view + + @commands.command(name="rules_coin") + async def rules_coin(self, ctx: commands.Context): + e = discord.Embed(title="๐Ÿช™ Coin Flip โ€” Rules", color=discord.Color.blurple()) + e.description = ( + "Pick **Heads** or **Tails**, set your **Bet**, and flip.\n\n" + "**Payout:** 1:1 (win doubles your bet; loss loses your bet)\n" + f"**Limits:** min bet ${COINFLIP_MIN_CHIP} โ€ข per flip ${COINFLIP_MIN_BET}โ€“${COINFLIP_MAX_BET}\n" + "_Tip: Use ร—2 / ยฝ to adjust quickly. The panel times out after a bit of inactivity._" + ) + await ctx.send(embed=e) + +async def setup(bot): + await bot.add_cog(CoinFlip(bot)) + diff --git a/src/cogs/economy.py b/src/cogs/economy.py new file mode 100644 index 0000000..4f1d77b --- /dev/null +++ b/src/cogs/economy.py @@ -0,0 +1,325 @@ +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) ---------------- +class RulesView(discord.ui.View): + def __init__(self): + super().__init__(timeout=60) + self.add_item(RulesSelect()) + +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"), + ] + super().__init__(placeholder="Choose a gameโ€ฆ", options=options, min_values=1, max_values=1) + + async def callback(self, interaction: discord.Interaction): + choice = self.values[0] + if choice == "Blackjack": + embed = rules_blackjack_embed() + elif choice == "Slots": + embed = rules_slots_embed() + elif choice == "Roulette (Mini)": + embed = rules_roulette_mini_embed() + elif choice == "Towers": + embed = rules_towers_embed() + elif choice == "Baccarat": + embed = rules_baccarat_embed() + else: # Coin Flip + embed = rules_coin_embed() + await interaction.response.edit_message(embed=embed, view=self.view) + +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." + ) + return e + + +def rules_towers_embed(): + e = discord.Embed(title="๐Ÿ‰ Towers โ€” Rules", color=discord.Color.dark_teal()) + e.description = ( + "Nine tiers high. Pick one tile per row.\n" + "โ€ข **Easy:** 4 slots / 1 bad (3 safe)\n" + "โ€ข **Medium:** 3 / 1\n" + "โ€ข **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. " + "Cash out anytime; hit a bad tile and you lose the bet." + ) + return e + +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}" + ) + return e + +def rules_blackjack_embed(): + e = discord.Embed(title="๐Ÿƒ Blackjack โ€” Rules", color=discord.Color.blue()) + 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" + "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```" + + 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" + "โ€ข 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"**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)." + ) + e.add_field(name="Payout Multipliers", value=table, inline=False) + return e + +def rules_roulette_mini_embed(): + e = discord.Embed(title="๐ŸŽฏ Mini Roulette โ€” Rules", color=discord.Color.gold()) + e.description = ( + "**Wheel:** 19 pockets (0โ€“18). Casino edge โ‰ˆ 1/19.\n\n" + "**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**." + ) + return e + +# ---------------- Casino menu ---------------- +class CasinoView(discord.ui.View): + """Buttons to launch any game for the invoking user.""" + def __init__(self, cog: "Economy", ctx: commands.Context): + super().__init__(timeout=120) + self.cog = cog + self.ctx = ctx + self.user_id = ctx.author.id + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.user_id: + await interaction.response.send_message("This menu belongs to someone else.", ephemeral=True) + return False + return True + + async def _launch(self, interaction: discord.Interaction, command_name: str): + # Acknowledge the click fast so Discord doesn't show "interaction failed" + try: + await interaction.response.defer(thinking=False) + except Exception: + pass + + # Delete the casino menu message to avoid clutter + try: + await interaction.message.delete() + except Exception: + # Fallback: disable the view & mark as launching + for item in self.children: + item.disabled = True + try: + await interaction.message.edit(content="๐ŸŽฎ Launchingโ€ฆ", view=self) + except Exception: + pass + + # Now invoke the actual game 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="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="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="Baccarat", style=discord.ButtonStyle.secondary, emoji="๐ŸŽด", row=2) + async def baccarat(self, itx: discord.Interaction, _): await self._launch(itx, "baccarat") + + +class Economy(commands.Cog): + def __init__(self, bot): + self.bot = bot + + async def resolve_name(self, ctx, user_id: int) -> str: + if ctx.guild: + m = ctx.guild.get_member(user_id) + if m: + return m.display_name + try: + u = await self.bot.fetch_user(user_id) + return getattr(u, "global_name", None) or u.name + except Exception: + return f"User {user_id}" + + @commands.command(name="casino") + async def casino(self, ctx: commands.Context): + """Open the casino launcher with game buttons.""" + cash, spins = db.get_wallet(ctx.author.id) + e = discord.Embed( + title="๐ŸŸ๏ธ Casino", + description=( + "Pick a game below to start playing.\n" + "Use **!rules** for how-to-play and payouts." + ), + color=discord.Color.blurple() + ) + e.add_field(name="Your Balance", value=f"${cash:,} โ€ข ๐ŸŽŸ๏ธ {spins} free spins", inline=False) + await ctx.send(embed=e, view=CasinoView(self, ctx)) + + @commands.command(name="balance", aliases=["bal", "cash", "stats"]) + async def balance(self, ctx, user: discord.Member = None): + target = user or ctx.author + s = db.get_full_stats(target.id) + + def pct(n: int, d: int) -> str: + return f"{(n/d*100):.1f}%" if d else "0.0%" + + embed = discord.Embed( + title=f"๐Ÿ’ฐ {target.display_name}'s Stats", + color=discord.Color.gold() + ) + 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) + embed.add_field(name="Overall W/L", value=f"{s['games_won']}/{s['games_played']} ({pct(s['games_won'], s['games_played'])})", inline=True) + 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) + + await ctx.send(embed=embed) + + @commands.command(name="daily") + async def daily(self, ctx): + user_id = ctx.author.id + 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 + 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']}**"), + color=discord.Color.green() + ) + await ctx.send(embed=embed) + + @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!") + lines = [] + for i, (uid, cash) in enumerate(rows, 1): + name = await self.resolve_name(ctx, uid) + medal = "๐Ÿฅ‡" if i == 1 else "๐Ÿฅˆ" if i == 2 else "๐Ÿฅ‰" if i == 3 else f"{i}." + lines.append(f"{medal} **{name}** - ${cash}") + embed = discord.Embed(title="๐Ÿ† Cash Leaderboard", description="\n".join(lines), color=discord.Color.gold()) + await ctx.send(embed=embed) + + @commands.command(name="status") + async def status(self, ctx): + try: + count = db.user_counts() + db_status = f"โœ… Connected ({count} users)" + except Exception as e: + db_status = f"โŒ Error: {e}" + embed = discord.Embed(title="๐Ÿค– Bot Status", color=discord.Color.green()) + embed.add_field(name="Database", value=db_status, inline=False) + embed.add_field(name="Servers", value=len(self.bot.guilds), inline=True) + await ctx.send(embed=embed) + + @commands.command(name="rules") + async def rules(self, ctx): + embed = discord.Embed(title="๐Ÿ“œ Game Rules", description="Choose a game from the menu below.", color=discord.Color.blurple()) + await ctx.send(embed=embed, view=RulesView()) + + @commands.command(name="topup", aliases=["boost","faucet"]) + async def topup(self, ctx): + uid = ctx.author.id + can, secs_left = db.can_claim_topup(uid, TOPUP_COOLDOWN_MINUTES) + if not can: + mins = secs_left // 60; secs = secs_left % 60 + return await ctx.send(f"โณ You can claim again in **{mins}m {secs}s**.") + db.claim_topup(uid, TOPUP_AMOUNT) + stats = db.get_full_stats(uid) + embed = discord.Embed( + title="โšก Top-up Claimed!", + description=f"You received **${TOPUP_AMOUNT}**.\nNew balance: **${stats['cash']}**", + color=discord.Color.green() + ) + await ctx.send(embed=embed) + + @commands.command(name="tip", aliases=["pay", "gift", "give"]) + async def tip(self, ctx: commands.Context, member: discord.Member, amount: str, *, note: str | None = None): + if member.bot: return await ctx.send("๐Ÿค– You canโ€™t tip bots.") + if member.id == ctx.author.id: return await ctx.send("๐Ÿชž You canโ€™t tip yourself.") + try: amt = int("".join(ch for ch in amount if ch.isdigit())) + except Exception: return await ctx.send("โŒ Please provide a valid amount, e.g. `!tip @user 500`.") + if amt <= 0: return await ctx.send("โŒ Tip amount must be at least $1.") + if amt > 1_000_000: return await ctx.send("๐Ÿ›‘ Thatโ€™s a bit much. Max tip is $1,000,000 by default.") + ok, err, from_cash, to_cash = db.transfer_cash(ctx.author.id, member.id, amt) + if not ok: + 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.") + sender = await self.resolve_name(ctx, ctx.author.id) + recv = await self.resolve_name(ctx, member.id) + desc = f"**{sender}** โ†’ **{recv}**\nAmount: **${amt:,}**" + if note: desc += f"\nโ€œ{note}โ€" + e = discord.Embed(title="๐ŸŽ Tip sent!", description=desc, color=discord.Color.green()) + e.add_field(name="Your new balance", value=f"${from_cash:,}", inline=True) + e.add_field(name=f"{recv}'s new balance", value=f"${to_cash:,}", inline=True) + await ctx.send(embed=e) + +async def setup(bot): + await bot.add_cog(Economy(bot)) + diff --git a/src/cogs/roulette.py b/src/cogs/roulette.py new file mode 100644 index 0000000..ec25c0f --- /dev/null +++ b/src/cogs/roulette.py @@ -0,0 +1,590 @@ +# src/cogs/roulette.py +# Mini Roulette (0โ€“18) with a single ephemeral "Bet Builder" panel. +# - Outside/Inside flows live in ONE popup; each step edits the same ephemeral message. +# - Back / Close buttons on every picker. +# - Clear Slip also resets chip to $0. +# - Requires: +# from ..utils.constants import (ROULETTE_NUMBERS, ROULETTE_RED, ROULETTE_BLACK, +# ROULETTE_MIN_CHIP, ROULETTE_MIN_BET, ROULETTE_MAX_BET) +# from .. import db + +import random, discord +from typing import List, Tuple, Dict, Any, Optional +from discord.ext import commands + +from ..utils.constants import ( + ROULETTE_NUMBERS, ROULETTE_RED, ROULETTE_BLACK, + ROULETTE_MIN_CHIP, ROULETTE_MIN_BET, ROULETTE_MAX_BET, +) +from .. import db + +P = len(ROULETTE_NUMBERS) +assert P == 19, "Mini Roulette expects pockets 0..18" + +# ===== helpers ===== + +def spin() -> int: + return random.choice(ROULETTE_NUMBERS) + +def color_of(n: int) -> str: + if n == 0: return "green" + return "red" if n in ROULETTE_RED else "black" + +def column_of(n: int) -> Optional[int]: + if n == 0: return None + r = n % 3 + return 1 if r == 1 else 2 if r == 2 else 3 + +def street_rows() -> List[Tuple[int,int,int]]: + rows = [(a, a+1, a+2) for a in range(1, 19, 3)] # 1โ€“3,4โ€“6,โ€ฆ,16โ€“18 + rows += [(0,1,2), (0,2,3)] + return rows + +def sixlines_all() -> List[Tuple[int, int, int, int, int, int]]: + return [(a,a+1,a+2,a+3,a+4,a+5) for a in (1,4,7,10,13)] + +def six_group_of(n: int) -> Optional[int]: + if n == 0: return None + if 1 <= n <= 6: return 1 + if 7 <= n <= 12: return 2 + if 13 <= n <= 18: return 3 + return None + +def payout_for(kind: str) -> int: + # Pay (18/K - 1):1 โ†’ house edge โ‰ˆ 1/19 across bet types + ksize = { + "straight": 1, "split": 2, "street": 3, "sixline": 6, + "column": 6, "sixes": 6, "redblack": 9, "evenodd": 9, "lowhigh": 9 + }[kind] + return int((18 / ksize) - 1) + +# ===== Main slip view ===== + +class MiniRouletteView(discord.ui.View): + def __init__(self, bot, user_id: int, *, timeout: int = 120): + super().__init__(timeout=timeout) + self.bot = bot + self.user_id = user_id + self.chip = max(ROULETTE_MIN_CHIP, 100) + self.bets: List[Dict[str,Any]] = [] + self.message: Optional[discord.Message] = None + self.last_result: Optional[int] = None + self._busy = False + + # --- helpers --- + def total_bet(self) -> int: + return sum(b["amount"] for b in self.bets) + + def embed_slip(self) -> discord.Embed: + slip = "\n".join(f"โ€ข {b['label']}" for b in self.bets) if self.bets else "_(no bets yet)_" + e = discord.Embed(title="๐ŸŽฏ Mini Roulette (0โ€“18)", color=discord.Color.dark_gold()) + if self.last_result is not None: + n = self.last_result + c = color_of(n).title() + col = column_of(n) or "โ€”" + sx = six_group_of(n) or "โ€”" + e.description = f"Last: **{n}** ({c}) โ€ข Column **{col}** โ€ข Sixes **{sx}**" + e.add_field(name="Your Bet Slip", value=slip, inline=False) + hint = " (set one โฌ†๏ธ)" if self.chip == 0 else "" + e.add_field(name="Totals", value=f"Spin total: **${self.total_bet()}** โ€ข Chip: **${self.chip}**{hint}", inline=False) + e.set_footer(text="Add bets below, then Spin. View times out after ~2 minutes.") + return e + + def disable_all_items(self): + for item in self.children: + item.disabled = True + + async def on_timeout(self): + self.disable_all_items() + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + # --- chip controls (row 0; โ‰ค5 per row) --- + @discord.ui.button(label="+10", style=discord.ButtonStyle.secondary, row=0) + async def chip_p10(self, itx: discord.Interaction, _): + await self._chip_delta(itx, 10) + + @discord.ui.button(label="+50", style=discord.ButtonStyle.secondary, row=0) + async def chip_p50(self, itx: discord.Interaction, _): + await self._chip_delta(itx, 50) + + @discord.ui.button(label="+100", style=discord.ButtonStyle.secondary, row=0) + async def chip_p100(self, itx: discord.Interaction, _): + await self._chip_delta(itx, 100) + + @discord.ui.button(label="ร—2", style=discord.ButtonStyle.secondary, row=0) + async def chip_x2(self, itx: discord.Interaction, _): + await self._chip_mul(itx, 2) + + @discord.ui.button(label="ยฝ", style=discord.ButtonStyle.secondary, row=0) + async def chip_half(self, itx: discord.Interaction, _): + await self._chip_div(itx, 2) + + @discord.ui.button(label="MAX", style=discord.ButtonStyle.secondary, row=1) + async def chip_max(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: + return await itx.response.send_message("This isnโ€™t your roulette session!", ephemeral=True) + cash, _ = db.get_wallet(self.user_id) + self.chip = max(ROULETTE_MIN_CHIP, min(ROULETTE_MAX_BET, cash)) + await itx.response.edit_message(embed=self.embed_slip(), view=self) + + async def _chip_delta(self, itx: discord.Interaction, d: int): + if itx.user.id != self.user_id: + return await itx.response.send_message("This isnโ€™t your roulette session!", ephemeral=True) + self.chip = max(ROULETTE_MIN_CHIP, min(ROULETTE_MAX_BET, self.chip + d)) + await itx.response.edit_message(embed=self.embed_slip(), view=self) + + async def _chip_mul(self, itx: discord.Interaction, m: float): + if itx.user.id != self.user_id: + return await itx.response.send_message("This isnโ€™t your roulette session!", ephemeral=True) + self.chip = max(ROULETTE_MIN_CHIP, min(ROULETTE_MAX_BET, int(self.chip * m))) + await itx.response.edit_message(embed=self.embed_slip(), view=self) + + async def _chip_div(self, itx: discord.Interaction, d: float): + if itx.user.id != self.user_id: + return await itx.response.send_message("This isnโ€™t your roulette session!", ephemeral=True) + self.chip = max(ROULETTE_MIN_CHIP, int(self.chip / d)) + await itx.response.edit_message(embed=self.embed_slip(), view=self) + + # --- bet building entry (row 1) --- + @discord.ui.button(label="Add Outside / Inside", style=discord.ButtonStyle.secondary, emoji="๐Ÿงพ", row=1) + async def open_builder(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: + return await itx.response.send_message("This isnโ€™t your roulette session!", ephemeral=True) + if self.chip < ROULETTE_MIN_CHIP: + return await itx.response.send_message( + f"Set a chip amount first (min ${ROULETTE_MIN_CHIP}) using the chip buttons.", ephemeral=True + ) + await itx.response.send_message("Bet Builder โ€” choose an option:", ephemeral=True, view=BetPanelRoot(self)) + + @discord.ui.button(label="Clear Slip", style=discord.ButtonStyle.danger, emoji="๐Ÿงน", row=1) + async def clear(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: + return await itx.response.send_message("This isnโ€™t your roulette session!", ephemeral=True) + self.bets.clear() + self.chip = 0 # reset chip as requested + await itx.response.edit_message(embed=self.embed_slip(), view=self) + + # --- spin & resolve (row 2) --- + @discord.ui.button(label="Spin", style=discord.ButtonStyle.success, emoji="โ–ถ๏ธ", row=2) + async def spin_btn(self, itx: discord.Interaction, _): + if itx.user.id != self.user_id: + return await itx.response.send_message("This isnโ€™t your roulette session!", ephemeral=True) + if self._busy: + return await itx.response.send_message("Hold onโ€”resolving the previous spinโ€ฆ", ephemeral=True) + if not self.bets: + return await itx.response.send_message("Add at least one bet first.", ephemeral=True) + + total = self.total_bet() + if total < ROULETTE_MIN_BET: + return await itx.response.send_message(f"Min spin total is ${ROULETTE_MIN_BET}.", ephemeral=True) + if total > ROULETTE_MAX_BET: + return await itx.response.send_message(f"Max spin total is ${ROULETTE_MAX_BET}.", ephemeral=True) + cash, _ = db.get_wallet(self.user_id) + if cash < total: + return await itx.response.send_message(f"Not enough cash for **${total}**.", ephemeral=True) + + self._busy = True + try: + db.add_cash(self.user_id, -total) + + n = spin() + self.last_result = n + c = color_of(n) + col = column_of(n) + sx = six_group_of(n) + + def bet_wins(b: Dict[str,Any]) -> bool: + kind, t = b["kind"], b["targets"] + if kind in ("straight","split","street","sixline"): + return n in t + if kind == "column": return col == int(t) + if kind == "sixes": return sx == int(t) + if kind == "redblack": return (n != 0) and (c == t) + if kind == "evenodd": return (n != 0) and ((n % 2 == 0) == (t == "even")) + if kind == "lowhigh": return (n != 0) and ((1 <= n <= 9) if t == "low" else (10 <= n <= 18)) + return False + + total_return = 0 + lines = [] + for b in self.bets: + if bet_wins(b): + win_amt = b["amount"] * b["payout"] + total_return += win_amt + b["amount"] + lines.append(f"โœ… {b['label']} โ†’ +${win_amt}") + else: + lines.append(f"โŒ {b['label']}") + + if total_return: + db.add_cash(self.user_id, total_return) + try: + db.record_roulette(self.user_id, total_bet=total, total_return=total_return) + except Exception: + pass + + cash2, _ = db.get_wallet(self.user_id) + head = f"**Result:** {n} ({c.title()})" + if col: head += f" โ€ข Column {col}" + if sx: head += f" โ€ข Sixes {sx}" + net = total_return - total + summary = f"**Bet:** ${total} โ€ข **Return:** ${total_return} โ€ข **Net:** {'+' if net>=0 else ''}${net}\n**Balance:** ${cash2}" + + e = discord.Embed(title="๐ŸŽฏ Mini Roulette (0โ€“18)", color=discord.Color.gold(), description=head) + e.add_field(name="Bets", value="\n".join(lines), inline=False) + e.add_field(name="Summary", value=summary, inline=False) + e.set_footer(text="Spin Again to reuse your slip, or Clear to start over.") + await itx.response.edit_message(embed=e, view=self) + finally: + self._busy = False + + @discord.ui.button(label="Spin Again", style=discord.ButtonStyle.primary, emoji="๐Ÿ”", row=2) + async def spin_again(self, itx: discord.Interaction, btn): + await self.spin_btn.callback(self, itx, btn) + +# ===== Single Ephemeral "Bet Builder" ===== + +class BetPanelRoot(discord.ui.View): + """Top-level builder: one ephemeral panel that everything else edits.""" + def __init__(self, parent: MiniRouletteView): + super().__init__(timeout=60) + self.parent = parent + + @discord.ui.button(label="Add Outside Bet", style=discord.ButtonStyle.primary, emoji="๐ŸŸฆ", row=0) + async def go_outside(self, itx: discord.Interaction, _): + await itx.response.edit_message(content="Outside bet โ€” pick a target:", view=OutsidePicker(self.parent)) + + @discord.ui.button(label="Add Inside Bet", style=discord.ButtonStyle.primary, emoji="๐ŸŸฅ", row=0) + async def go_inside(self, itx: discord.Interaction, _): + await itx.response.edit_message(content="Inside bet โ€” choose type:", view=InsideKindPicker(self.parent)) + + @discord.ui.button(label="Close", style=discord.ButtonStyle.secondary, emoji="โœ–๏ธ", row=0) + async def close_panel(self, itx: discord.Interaction, _): + await itx.response.edit_message(content="Bet Builder closed.", view=None) + +# ---- Outside flow (single select) ---- + +class OutsidePicker(discord.ui.View): + def __init__(self, parent: MiniRouletteView): + super().__init__(timeout=60); self.parent = parent + opts = [ + discord.SelectOption(label="Red", value="redblack:red", emoji="๐Ÿ”ด"), + discord.SelectOption(label="Black", value="redblack:black", emoji="โšซ"), + discord.SelectOption(label="Even", value="evenodd:even"), + discord.SelectOption(label="Odd", value="evenodd:odd"), + discord.SelectOption(label="Low (1โ€“9)", value="lowhigh:low"), + discord.SelectOption(label="High (10โ€“18)", value="lowhigh:high"), + discord.SelectOption(label="Column 1", value="column:1"), + discord.SelectOption(label="Column 2", value="column:2"), + discord.SelectOption(label="Column 3", value="column:3"), + discord.SelectOption(label="Sixes 1โ€“6", value="sixes:1"), + discord.SelectOption(label="Sixes 7โ€“12", value="sixes:2"), + discord.SelectOption(label="Sixes 13โ€“18", value="sixes:3"), + ] + sel = discord.ui.Select(placeholder="Chooseโ€ฆ", min_values=1, max_values=1, options=opts) + self.add_item(sel) + + async def on_pick(itx: discord.Interaction): + kind, target = sel.values[0].split(":") + label = { + "redblack:red": "Red", "redblack:black": "Black", + "evenodd:even": "Even", "evenodd:odd": "Odd", + "lowhigh:low": "Low (1โ€“9)", "lowhigh:high": "High (10โ€“18)", + "column:1": "Column 1", "column:2": "Column 2", "column:3": "Column 3", + "sixes:1": "Sixes 1โ€“6", "sixes:2": "Sixes 7โ€“12", "sixes:3": "Sixes 13โ€“18", + }[sel.values[0]] + bet = {"kind": kind, "targets": target, "amount": self.parent.chip, + "label": f"{label} ${self.parent.chip}", "payout": payout_for(kind)} + self.parent.bets.append(bet) + await itx.response.edit_message(content=f"โœ… Added: {label}\n\nBet Builder โ€” choose another option:", + view=BetPanelRoot(self.parent)) + try: + await self.parent.message.edit(embed=self.parent.embed_slip(), view=self.parent) + except Exception: + pass + sel.callback = on_pick + + @discord.ui.button(label="Back", style=discord.ButtonStyle.secondary, row=1) + async def back(self, itx: discord.Interaction, _): + await itx.response.edit_message(content="Bet Builder โ€” choose an option:", view=BetPanelRoot(self.parent)) + + @discord.ui.button(label="Close", style=discord.ButtonStyle.secondary, emoji="โœ–๏ธ", row=1) + async def close(self, itx: discord.Interaction, _): + await itx.response.edit_message(content="Bet Builder closed.", view=None) + +# ---- Inside flow: choose type ---- + +class InsideKindPicker(discord.ui.View): + def __init__(self, parent: MiniRouletteView): + super().__init__(timeout=60); self.parent = parent + sel = discord.ui.Select( + placeholder="Inside typeโ€ฆ", min_values=1, max_values=1, + options=[ + discord.SelectOption(label="Straight (single number)", value="straight"), + discord.SelectOption(label="Split (two adjacent)", value="split"), + discord.SelectOption(label="Street (row of 3)", value="street"), + discord.SelectOption(label="Six-line (two rows)", value="sixline"), + ] + ) + self.add_item(sel) + + async def on_pick(itx: discord.Interaction): + k = sel.values[0] + if k == "straight": + await itx.response.edit_message(content="Straight โ€” pick a number:", view=StraightPicker(self.parent)) + elif k == "split": + await itx.response.edit_message(content="Split โ€” pick the first number:", view=SplitAnchorPicker(self.parent)) + elif k == "street": + await itx.response.edit_message(content="Street โ€” pick a row:", view=StreetPicker(self.parent)) + else: + await itx.response.edit_message(content="Six-line โ€” pick a range:", view=SixlinePicker(self.parent)) + sel.callback = on_pick + + @discord.ui.button(label="Back", style=discord.ButtonStyle.secondary, row=1) + async def back(self, itx: discord.Interaction, _): + await itx.response.edit_message(content="Bet Builder โ€” choose an option:", view=BetPanelRoot(self.parent)) + + @discord.ui.button(label="Close", style=discord.ButtonStyle.secondary, emoji="โœ–๏ธ", row=1) + async def close(self, itx: discord.Interaction, _): + await itx.response.edit_message(content="Bet Builder closed.", view=None) + +# ---- Straight picker (buttons) ---- + +class StraightPicker(discord.ui.View): + def __init__(self, parent: MiniRouletteView): + super().__init__(timeout=60); self.parent = parent + # Row 0: 0 + nav + self.add_item(self._button_num(0, row=0)) + self.add_item(self._back_btn(row=0)) + self.add_item(self._close_btn(row=0)) + # Rows 1.. for 1..18 (โ‰ค5 per row) + n = 1; row = 1 + while n <= 18: + for _ in range(5): + if n > 18: break + self.add_item(self._button_num(n, row=row)); n += 1 + row += 1 + + def _button_num(self, n: int, row: int): + style = (discord.ButtonStyle.secondary if n == 0 + else discord.ButtonStyle.success if n in ROULETTE_RED + else discord.ButtonStyle.primary) + btn = discord.ui.Button(label=str(n), style=style, row=row) + async def cb(itx: discord.Interaction): + bet = {"kind":"straight","targets":(n,), "amount":self.parent.chip, + "label": f"Straight {n} ${self.parent.chip}", "payout": payout_for("straight")} + self.parent.bets.append(bet) + await itx.response.edit_message(content=f"โœ… Added: Straight {n}\n\nInside โ€” choose type:", + view=InsideKindPicker(self.parent)) + try: + await self.parent.message.edit(embed=self.parent.embed_slip(), view=self.parent) + except Exception: + pass + btn.callback = cb; return btn + + def _back_btn(self, row:int): + btn = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Inside bet โ€” choose type:", view=InsideKindPicker(self.parent)) + return btn + + def _close_btn(self, row:int): + btn = discord.ui.Button(label="Close", style=discord.ButtonStyle.secondary, emoji="โœ–๏ธ", row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Bet Builder closed.", view=None) + return btn + +# ---- Split pickers (anchor โ†’ neighbor) ---- + +class SplitAnchorPicker(discord.ui.View): + def __init__(self, parent: MiniRouletteView): + super().__init__(timeout=60); self.parent = parent + self.add_item(self._back_btn(row=0)); self.add_item(self._close_btn(row=0)) + self.add_item(self._button_num(0, row=0)) + n = 1; row = 1 + while n <= 18: + for _ in range(5): + if n > 18: break + self.add_item(self._button_num(n, row=row)); n += 1 + row += 1 + + def _neighbors(self, a: int) -> List[int]: + if a == 0: return [1,2,3] + opts = set() + if a % 3 != 1: opts.add(a-1) + if a % 3 != 0: opts.add(a+1) + if a - 3 >= 1: opts.add(a-3) + if a + 3 <= 18: opts.add(a+3) + return sorted(opts) + + def _button_num(self, n:int, row:int): + style = (discord.ButtonStyle.secondary if n == 0 + else discord.ButtonStyle.success if n in ROULETTE_RED + else discord.ButtonStyle.primary) + btn = discord.ui.Button(label=str(n), style=style, row=row) + async def cb(itx: discord.Interaction): + neigh = self._neighbors(n) + await itx.response.edit_message(content=f"Split โ€” anchor **{n}**. Pick the second number:", + view=SplitNeighborPicker(self.parent, n, neigh)) + btn.callback = cb; return btn + + def _back_btn(self, row:int): + btn = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Inside bet โ€” choose type:", view=InsideKindPicker(self.parent)) + return btn + + def _close_btn(self, row:int): + btn = discord.ui.Button(label="Close", style=discord.ButtonStyle.secondary, emoji="โœ–๏ธ", row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Bet Builder closed.", view=None) + return btn + +class SplitNeighborPicker(discord.ui.View): + def __init__(self, parent: MiniRouletteView, anchor: int, neigh: List[int]): + super().__init__(timeout=60); self.parent = parent; self.anchor = anchor + # Row 0: Back/Close + self.add_item(self._back_btn(row=0)); self.add_item(self._close_btn(row=0)) + # Neighbor buttons from row 1 onward (โ‰ค5 per row) + row = 1 + for i, m in enumerate(neigh): + if i and i % 5 == 0: row += 1 + style = (discord.ButtonStyle.secondary if m == 0 + else discord.ButtonStyle.success if m in ROULETTE_RED + else discord.ButtonStyle.primary) + btn = discord.ui.Button(label=str(m), style=style, row=row) + btn.callback = self._make_cb(m) + self.add_item(btn) + + def _make_cb(self, m:int): + async def cb(itx: discord.Interaction): + a,b = sorted((self.anchor, m)) + bet = {"kind":"split","targets":(a,b),"amount":self.parent.chip, + "label": f"Split {a}-{b} ${self.parent.chip}","payout": payout_for("split")} + self.parent.bets.append(bet) + await itx.response.edit_message(content=f"โœ… Added: Split {a}-{b}\n\nInside โ€” choose type:", + view=InsideKindPicker(self.parent)) + try: + await self.parent.message.edit(embed=self.parent.embed_slip(), view=self.parent) + except Exception: + pass + return cb + + def _back_btn(self, row:int): + btn = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Split โ€” pick the first number:", view=SplitAnchorPicker(self.parent)) + return btn + + def _close_btn(self, row:int): + btn = discord.ui.Button(label="Close", style=discord.ButtonStyle.secondary, emoji="โœ–๏ธ", row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Bet Builder closed.", view=None) + return btn + +# ---- Street ---- + +class StreetPicker(discord.ui.View): + def __init__(self, parent: MiniRouletteView): + super().__init__(timeout=60); self.parent = parent + self.add_item(self._back_btn(row=0)); self.add_item(self._close_btn(row=0)) + rows = street_rows() + row = 1 + for i, trio in enumerate(rows): + if i and i % 5 == 0: row += 1 + label = "0-1-2" if trio == (0,1,2) else "0-2-3" if trio == (0,2,3) else f"{trio[0]}โ€“{trio[2]}" + btn = discord.ui.Button(label=label, style=discord.ButtonStyle.secondary, row=row) + btn.callback = self._make_cb(trio, label) + self.add_item(btn) + + def _make_cb(self, trio: Tuple[int,int,int], label: str): + async def cb(itx: discord.Interaction): + bet = {"kind":"street","targets":tuple(trio),"amount":self.parent.chip, + "label": f"Street {label} ${self.parent.chip}", "payout": payout_for("street")} + self.parent.bets.append(bet) + await itx.response.edit_message(content=f"โœ… Added: Street {label}\n\nInside โ€” choose type:", + view=InsideKindPicker(self.parent)) + try: + await self.parent.message.edit(embed=self.parent.embed_slip(), view=self.parent) + except Exception: + pass + return cb + + def _back_btn(self, row:int): + btn = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Inside bet โ€” choose type:", view=InsideKindPicker(self.parent)) + return btn + + def _close_btn(self, row:int): + btn = discord.ui.Button(label="Close", style=discord.ButtonStyle.secondary, emoji="โœ–๏ธ", row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Bet Builder closed.", view=None) + return btn + +# ---- Six-line ---- + +class SixlinePicker(discord.ui.View): + def __init__(self, parent: MiniRouletteView): + super().__init__(timeout=60); self.parent = parent + self.add_item(self._back_btn(row=0)); self.add_item(self._close_btn(row=0)) + sixes = sixlines_all() + row = 1 + for i, s in enumerate(sixes): + if i and i % 5 == 0: row += 1 + label = f"{s[0]}โ€“{s[-1]}" + btn = discord.ui.Button(label=label, style=discord.ButtonStyle.secondary, row=row) + btn.callback = self._make_cb(s, label) + self.add_item(btn) + + def _make_cb(self, group: Tuple[int,...], label: str): + async def cb(itx: discord.Interaction): + bet = {"kind":"sixline","targets":tuple(group),"amount":self.parent.chip, + "label": f"Six-line {label} ${self.parent.chip}", "payout": payout_for("sixline")} + self.parent.bets.append(bet) + await itx.response.edit_message(content=f"โœ… Added: Six-line {label}\n\nInside โ€” choose type:", + view=InsideKindPicker(self.parent)) + try: + await self.parent.message.edit(embed=self.parent.embed_slip(), view=self.parent) + except Exception: + pass + return cb + + def _back_btn(self, row:int): + btn = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Inside bet โ€” choose type:", view=InsideKindPicker(self.parent)) + return btn + + def _close_btn(self, row:int): + btn = discord.ui.Button(label="Close", style=discord.ButtonStyle.secondary, emoji="โœ–๏ธ", row=row) + btn.callback = lambda itx: itx.response.edit_message(content="Bet Builder closed.", view=None) + return btn + +# ===== Cog ===== + +_active_sessions: Dict[int, MiniRouletteView] = {} + +class MiniRoulette(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="roulette") + async def roulette(self, ctx: commands.Context): + uid = ctx.author.id + if uid in _active_sessions: + await ctx.send("โŒ You already have an active roulette session. Finish it or wait for it to time out.") + return + view = MiniRouletteView(self.bot, uid, timeout=120) + msg = await ctx.send(embed=view.embed_slip(), view=view) + view.message = msg + _active_sessions[uid] = view + + @commands.command(name="rules_roulette", aliases=["roulette_rules","rrules"]) + async def rules_roulette(self, ctx: commands.Context): + e = discord.Embed(title="๐ŸŽฏ Mini Roulette (0โ€“18) โ€” Rules", color=discord.Color.gold()) + e.description = ( + "**Wheel:** 19 pockets (0โ€“18). Casino edge โ‰ˆ 1/19 by paying slightly under fair odds.\n\n" + "**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" + "Open **Add Outside / Inside** to build your slip. Set **Chip** with the buttons, then **Spin**. " + "Use **Spin Again** to reuse your slip." + ) + await ctx.send(embed=e) + +async def setup(bot): + await bot.add_cog(MiniRoulette(bot)) + diff --git a/src/cogs/slots.py b/src/cogs/slots.py new file mode 100644 index 0000000..7c0e835 --- /dev/null +++ b/src/cogs/slots.py @@ -0,0 +1,340 @@ +import random, discord +from typing import Optional, List +from discord.ext import commands +from .. import db +from ..utils.constants import ( + SYMBOLS, WEIGHTS, PAYOUTS, + SLOTS_DEFAULT_BET, SLOTS_MIN_BET, SLOTS_MAX_BET, + SPECIAL_SYMBOL, SPECIAL_BONUS_SPINS, WILDCARD_FACTOR +) + +# All 8 possible lines (indices must stay stable) +LINES = [ + # rows + [(0,0),(0,1),(0,2)], # 0: top row + [(1,0),(1,1),(1,2)], # 1: middle row + [(2,0),(2,1),(2,2)], # 2: bottom row + # columns + [(0,0),(1,0),(2,0)], # 3: left col + [(0,1),(1,1),(2,1)], # 4: mid col + [(0,2),(1,2),(2,2)], # 5: right col + # diagonals + [(0,0),(1,1),(2,2)], # 6: main diag + [(0,2),(1,1),(2,0)], # 7: anti diag +] + +# Presets users can select +LINE_PRESETS = { + "1": [1], # center row + "3": [0,1,2], # all rows + "5": [0,1,2,6,7], # rows + diagonals + "8": list(range(8)), # all lines +} + +# ---------- engine ---------- + +def spin_board(): + pick = lambda: random.choices(SYMBOLS, weights=WEIGHTS, k=1)[0] + return [[pick() for _ in range(3)] for _ in range(3)] + +def evaluate(board, active_indices: List[int]): + """ + Return (total_multiplier, bonus_spins, winning_lines) + - Only checks the active line indices. + - Wildcard rule: exactly one โญ + two identical fruits pays WILDCARD_FACTOR * fruit multiplier. + - โญโญโญ bonus on the middle row (index 1) triggers only if that line is active. + """ + total_mult = 0 + bonus_spins = 0 + winning_lines = [] + + for idx in active_indices: + line = LINES[idx] + a, b, c = (board[r][c] for (r,c) in line) + + # Straight three-of-a-kind + if a == b == c: + mult = PAYOUTS.get(a, 0) + total_mult += mult + winning_lines.append((idx, a, mult)) + continue + + # Wildcard: exactly one star + two identical non-star fruits + syms = [a, b, c] + star_count = syms.count(SPECIAL_SYMBOL) + if star_count == 1: + non_stars = [s for s in syms if s != SPECIAL_SYMBOL] + if len(non_stars) == 2 and non_stars[0] == non_stars[1]: + fruit = non_stars[0] + base = PAYOUTS.get(fruit, 0) + mult = max(1, int(base * WILDCARD_FACTOR)) # reduced payout for wild wins + total_mult += mult + winning_lines.append((idx, fruit, mult)) + continue + # โญโญ+fruit or mixed โ†’ no payout + + # โญโญโญ bonus spins on the middle row, but only if that line is active + if 1 in active_indices: + if all(board[r][c] == SPECIAL_SYMBOL for (r,c) in LINES[1]): + bonus_spins += SPECIAL_BONUS_SPINS + + return total_mult, bonus_spins, winning_lines + +def fancy_board(board, winning_lines): + """Monospace board with line markers.""" + win_idx = {idx for idx, _, _ in winning_lines} + + top = "โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”“" + mid = "โ”ฃโ”โ”โ”โ”โ•‹โ”โ”โ”โ”โ•‹โ”โ”โ”โ”โ”ซ" + bot = "โ”—โ”โ”โ”โ”โ”ปโ”โ”โ”โ”โ”ปโ”โ”โ”โ”โ”›" + + def row_str(r): + cells = [f"{board[r][c]}" for c in range(3)] + line = f" {cells[0]} {cells[1]} {cells[2]} " + if r == 0 and 0 in win_idx: line += " โ† win" + if r == 1 and 1 in win_idx: line += " โ† win" + if r == 2 and 2 in win_idx: line += " โ† win" + return line + + rows = [top, row_str(0), mid, row_str(1), mid, row_str(2), bot] + + col_marks = [] + if 3 in win_idx: col_marks.append("col 1") + if 4 in win_idx: col_marks.append("col 2") + if 5 in win_idx: col_marks.append("col 3") + diag_marks = [] + if 6 in win_idx: diag_marks.append("main diag") + if 7 in win_idx: diag_marks.append("anti diag") + + footer = [] + if col_marks: footer.append("Cols: " + ", ".join(col_marks)) + if diag_marks: footer.append("Diags: " + ", ".join(diag_marks)) + + text = "\n".join(rows) + if footer: + text += "\n" + " " * 2 + " โ€ข ".join(footer) + + return f"```\n{text}\n```" + +def build_slots_embed(uid: int, bet: int, used_free: bool, board, total_mult, bonus_spins, winning_lines, active_indices: List[int]): + # Split bet across active lines + n_lines = max(1, len(active_indices)) + line_bet = bet / n_lines + winnings = int(total_mult * line_bet) + + if winnings: + db.add_cash(uid, winnings) + if bonus_spins: + db.add_free_spins(uid, bonus_spins) + + db.record_slots(uid, winnings) + + cash, free_spins = db.get_wallet(uid) + grid = fancy_board(board, winning_lines) + + outcome = f"๐ŸŽ‰ You won **${winnings}**!" if winnings > 0 else "๐Ÿ˜ฌ No winning lines." + if bonus_spins: + outcome += f"\nโญ **Bonus!** You earned **{bonus_spins}** free spins!" + cost_line = "๐Ÿ†“ Used a free spin." if used_free else f"๐Ÿ’ธ Spin cost **${bet}**." + + desc = ( + f"{grid}\n\n" + f"{cost_line}\n{outcome}\n\n" + f"**Balance:** ${cash} | **Free Spins:** {free_spins}" + ) + embed = discord.Embed(title="๐ŸŽฐ Fruit Slots", description=desc, color=discord.Color.purple()) + embed.set_footer(text=f"Lines: {n_lines} โ€ข Per-line: ${line_bet:.0f} โ€ข Current bet: ${bet} โ€ข ๐Ÿ” spin โ€ข Set Bet / Lines to change") + return embed + +# ---------- UI pieces ---------- + +class LinesSelect(discord.ui.Select): + def __init__(self, parent_view): + self.parent_view = parent_view + options = [ + discord.SelectOption(label="1 line (center row)", value="1", default=(len(parent_view.active_indices)==1)), + discord.SelectOption(label="3 lines (rows)", value="3", default=(len(parent_view.active_indices)==3)), + discord.SelectOption(label="5 lines (rows+diags)",value="5", default=(len(parent_view.active_indices)==5)), + discord.SelectOption(label="8 lines (all)", value="8", default=(len(parent_view.active_indices)==8)), + ] + super().__init__(placeholder="Choose linesโ€ฆ", min_values=1, max_values=1, options=options, row=1) + + async def callback(self, interaction: discord.Interaction): + if interaction.user.id != self.parent_view.user_id: + await interaction.response.send_message("This isn't your slots session!", ephemeral=True); return + preset = self.values[0] + self.parent_view.active_indices = LINE_PRESETS.get(preset, list(range(8))) + for o in self.options: + o.default = (o.value == preset) + # Update footer only + if interaction.message and interaction.message.embeds: + emb = interaction.message.embeds[0] + n_lines = max(1, len(self.parent_view.active_indices)) + per_line = self.parent_view.bet / n_lines + emb.set_footer(text=f"Lines: {n_lines} โ€ข Per-line: ${per_line:.0f} โ€ข Current bet: ${self.parent_view.bet} โ€ข ๐Ÿ” spin โ€ข Set Bet / Lines to change") + await interaction.response.edit_message(embed=emb, view=self.parent_view) + else: + await interaction.response.edit_message(view=self.parent_view) + +class SetBetModal(discord.ui.Modal, title="Set Slots Bet"): + def __init__(self, view: "SlotsView"): + super().__init__() + self.view = view + self.amount = discord.ui.TextInput( + label=f"Amount (min {SLOTS_MIN_BET}, max {SLOTS_MAX_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 slots session!", 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) + + # clamp + amt = max(SLOTS_MIN_BET, min(SLOTS_MAX_BET, amt)) + self.view.bet = amt + + # acknowledge modal + await itx.response.defer(ephemeral=True) + + # refresh the message footer AND the cost line in the description + if self.view.message and self.view.message.embeds: + emb = self.view.message.embeds[0] + + # --- update the โ€œspin costโ€ line in the description --- + desc = emb.description or "" + lines = desc.splitlines() + for i, line in enumerate(lines): + # replace either the previous cost line or a โ€œused free spinโ€ line + if line.startswith("๐Ÿ’ธ") or line.startswith("๐Ÿ†“"): + lines[i] = f"๐Ÿ’ธ Spin cost **${self.view.bet}**." + break + emb.description = "\n".join(lines) + + # --- update footer (per-line) --- + n_lines = max(1, len(self.view.active_indices)) + per_line = self.view.bet / n_lines + emb.set_footer( + text=f"Lines: {n_lines} โ€ข Per-line: ${per_line:.0f} โ€ข Current bet: ${self.view.bet} โ€ข ๐Ÿ” spin โ€ข Set Bet / Lines to change" + ) + + await self.view.message.edit(embed=emb, view=self.view) + + +# ---------- View ---------- + +class SlotsView(discord.ui.View): + def __init__(self, bot: commands.Bot, user_id: int, bet: int, active_indices: List[int], *, timeout: float = 120.0): + super().__init__(timeout=timeout) + self.bot = bot + self.user_id = user_id + self.bet = bet + self.active_indices = active_indices[:] # copy + self.message: Optional[discord.Message] = None + self._busy = False + # Controls: Set Bet + Spin on row 0; Lines selector on row 1 + self.add_item(LinesSelect(self)) + + def disable_all_items(self): + for item in self.children: + item.disabled = True + + async def on_timeout(self): + self.disable_all_items() + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + # Set Bet (left of Spin) + @discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_bet_btn(self, interaction: discord.Interaction, _): + if interaction.user.id != self.user_id: + return await interaction.response.send_message("This isn't your slots session!", ephemeral=True) + await interaction.response.send_modal(SetBetModal(self)) + + @discord.ui.button(label="Spin Again", style=discord.ButtonStyle.primary, emoji="๐Ÿ”", row=0) + async def spin_again(self, interaction: discord.Interaction, _): + if interaction.user.id != self.user_id: + await interaction.response.send_message("This isn't your slots session!", ephemeral=True); return + if self._busy: + await interaction.response.send_message("Hold on, finishing the current spinโ€ฆ", ephemeral=True); return + + self._busy = True + try: + # Economy: consume a free spin, else charge full bet + used_free = db.consume_free_spin(self.user_id) + if not used_free: + cash, _ = db.get_wallet(self.user_id) + if cash < self.bet: + await interaction.response.send_message( + f"โŒ Not enough cash for **${self.bet}**. Use `!topup` or lower your bet/lines.", + ephemeral=True + ) + return + db.add_cash(self.user_id, -self.bet) + + board = spin_board() + total_mult, bonus_spins, winning_lines = evaluate(board, self.active_indices) + embed = build_slots_embed(self.user_id, self.bet, used_free, board, total_mult, bonus_spins, winning_lines, self.active_indices) + await interaction.response.edit_message(embed=embed, view=self) + finally: + self._busy = False + +# ---------- Cog ---------- + +class Slots(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="slots") + async def slots(self, ctx, bet: int = None): + """Spin the slots! Usage: !slots [bet] + - Set Bet (modal) and choose Lines on the message. + - Spin cost is the bet; it's split evenly across active lines. + - โญ is a wildcard for 'two of a kind + one โญ' (reduced payout). + - โญโญโญ on the middle row awards bonus spins, but only if that line is active. + """ + uid = ctx.author.id + + # sanitize bet + if bet is None: + bet = SLOTS_DEFAULT_BET + if bet < SLOTS_MIN_BET: + await ctx.send(f"โŒ Minimum bet is ${SLOTS_MIN_BET}."); return + if bet > SLOTS_MAX_BET: + await ctx.send(f"โŒ Maximum bet is ${SLOTS_MAX_BET}."); return + + # default lines preset = rows + diags (5 lines) + active_indices = [0,1,2,6,7] + + # Economy: consume a free spin if available; otherwise charge full bet + used_free = db.consume_free_spin(uid) + if not used_free: + cash, _ = db.get_wallet(uid) + if cash < bet: + await ctx.send(f"โŒ You need ${bet} (or a free spin) to play at that bet.") + return + db.add_cash(uid, -bet) + + # First spin + board = spin_board() + total_mult, bonus_spins, winning_lines = evaluate(board, active_indices) + embed = build_slots_embed(uid, bet, used_free, board, total_mult, bonus_spins, winning_lines, active_indices) + + # Send with view (Set Bet + Lines + Spin Again) + view = SlotsView(self.bot, uid, bet, active_indices, timeout=120) + msg = await ctx.send(embed=embed, view=view) + view.message = msg + +async def setup(bot): + await bot.add_cog(Slots(bot)) + diff --git a/src/cogs/towers.py b/src/cogs/towers.py new file mode 100644 index 0000000..8557a93 --- /dev/null +++ b/src/cogs/towers.py @@ -0,0 +1,372 @@ +# src/cogs/towers.py +import math, random, discord +from discord.ext import commands +from typing import Optional, Dict, List, Tuple + +from .. import db +from ..utils.constants import TOWERS_MIN_BET, TOWERS_MAX_BET, TOWERS_EDGE_PER_STEP + +# difficulty map: (row_size, bad_per_row) +DIFFS: Dict[str, Tuple[int, int]] = { + "Easy": (4, 1), # 3/4 safe + "Medium": (3, 1), # 2/3 safe + "Hard": (2, 1), # 1/2 safe + "Expert": (3, 2), # 1/3 safe + "Master": (4, 3), # 1/4 safe +} +DIFF_ORDER = list(DIFFS.keys()) +TIERS = 9 # rows high +_active_tower_sessions: Dict[int, "TowersView"] = {} + +def step_multiplier(row_size: int, bad: int) -> float: + """Per-step multiplier from odds with a small house edge.""" + p = (row_size - bad) / row_size + fair = 1.0 / p + return round(fair * (1.0 - TOWERS_EDGE_PER_STEP), 2) + +def cumulative_multiplier(steps_cleared: int, row_size: int, bad: int) -> float: + m = step_multiplier(row_size, bad) + return round(m ** steps_cleared, 2) if steps_cleared > 0 else 1.0 + +def fmt_money(n: int) -> str: + return f"${n:,}" + +class SetBetModal(discord.ui.Modal, title="Set Towers Bet"): + def __init__(self, view: "TowersView"): + super().__init__() + self.view = view + self.amount = discord.ui.TextInput( + label=f"Amount (min {TOWERS_MIN_BET}, max {TOWERS_MAX_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): + # only owner can submit changes + if itx.user.id != self.view.user_id: + return await itx.response.send_message("This isnโ€™t your Towers session!", ephemeral=True) + + if not self.view._ensure_can_modify_setup(): + return await itx.response.send_message("You canโ€™t change bet after starting. Cashout or Reset.", 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) + + amt = max(TOWERS_MIN_BET, min(TOWERS_MAX_BET, amt)) + self.view.bet = amt + + # Acknowledge, then update the original message + await itx.response.defer(ephemeral=True) + if self.view.message: + await self.view.message.edit(embed=self.view.embed(), view=self.view) + +class TowersView(discord.ui.View): + def __init__(self, user_id: int, *, timeout: int = 180): + super().__init__(timeout=timeout) + self.user_id = user_id + + # Configurable + self.bet = max(100, TOWERS_MIN_BET) + self.difficulty = "Easy" + + # Round state + self.started = False # bet debited yet? + self.ended = False # lost or cashed out + self.row_index = 0 # 0..TIERS-1 (index of NEXT row to play) + self.picked_cols: List[int] = [] # chosen col per cleared row + self.failed_at: Optional[Tuple[int, int]] = None # (row, col) of loss + + # Layout: bad positions per row + self.layout: List[set[int]] = [] + self._regen_layout() + + self.message: Optional[discord.Message] = None + self._busy = False + + # ---------- lifecycle ---------- + def _regen_layout(self): + size, bad = DIFFS[self.difficulty] + self.layout = [set(random.sample(range(size), bad)) for _ in range(TIERS)] + + def reset_round(self, *, keep_bet=True, keep_diff=True): + if not keep_bet: + self.bet = max(self.bet, TOWERS_MIN_BET) + if not keep_diff: + self.difficulty = "Easy" + self.started = False + self.ended = False + self.row_index = 0 + self.picked_cols.clear() + self.failed_at = None + self._regen_layout() + + async def on_timeout(self): + self.disable_all_items() + try: + if self.message: + await self.message.edit(view=self) + finally: + _active_tower_sessions.pop(self.user_id, None) + + def disable_all_items(self): + for item in self.children: + item.disabled = True + + # ---------- derived values ---------- + @property + def row_size(self) -> int: + return DIFFS[self.difficulty][0] + + @property + def bad_per_row(self) -> int: + return DIFFS[self.difficulty][1] + + @property + def can_cashout(self) -> bool: + return (not self.ended) and self.row_index > 0 and self.started + + def current_multiplier(self) -> float: + return cumulative_multiplier(self.row_index, self.row_size, self.bad_per_row) + + def potential_cashout(self) -> int: + return int(math.floor(self.bet * self.current_multiplier())) + + # ---------- rendering ---------- + def _row_str(self, row: int) -> str: + size = self.row_size + tiles = [] + fail_c = self.failed_at[1] if (self.failed_at and self.failed_at[0] == row) else None + picked_c = self.picked_cols[row] if row < len(self.picked_cols) else None + is_current = (row == self.row_index) and (not self.ended) + + for c in range(size): + if picked_c is not None: + tiles.append("๐ŸŸฉ" if c == picked_c else "โฌ›") + elif fail_c is not None: + tiles.append("๐Ÿ’€" if c == fail_c else "โฌ›") + else: + tiles.append("๐ŸŸจ" if is_current else "โฌ›") + + # center shorter rows so the board looks neat (target width ~4) + pad = 4 - size + if pad > 0: + left = pad // 2 + right = pad - left + return " " * (left * 2) + " ".join(tiles) + " " * (right * 2) + return " ".join(tiles) + + def draw_board(self) -> str: + # top row printed first + lines = [self._row_str(r) for r in range(TIERS - 1, -1, -1)] + return "```\n" + "\n".join(lines) + "\n```" + + def embed(self) -> discord.Embed: + cash, _ = db.get_wallet(self.user_id) + e = discord.Embed(title="๐Ÿ‰ Towers", color=discord.Color.dark_teal()) + e.description = self.draw_board() + + mm = self.current_multiplier() + pot = self.potential_cashout() + status = [ + f"Bet: **{fmt_money(self.bet)}**", + f"Difficulty: **{self.difficulty}**", + f"Progress: **{self.row_index}/{TIERS}**", + f"Multiplier: **x{mm:.2f}**", + f"Cashout now: **{fmt_money(pot)}**" if self.can_cashout else "Cashout now: **โ€”**", + ] + e.add_field(name="Status", value=" โ€ข ".join(status), inline=False) + e.add_field(name="Balance", value=fmt_money(cash), inline=True) + e.set_footer(text="Pick a tile on the current row. Cashout anytime. Reset to start a new board.") + return e + + # ---------- guards ---------- + async def _ensure_owner(self, itx: discord.Interaction): + if itx.user.id != self.user_id: + raise await itx.response.send_message("This isnโ€™t your Towers session!", ephemeral=True) + + def _ensure_can_modify_setup(self) -> bool: + return not self.started and not self.ended + + async def _update(self, itx: discord.Interaction): + await itx.response.edit_message(embed=self.embed(), view=self) + + async def _debit_if_needed(self) -> bool: + if self.started: + return True + cash, _ = db.get_wallet(self.user_id) + if cash < self.bet: + return False + db.add_cash(self.user_id, -self.bet) + self.started = True + return True + + async def _pick_col(self, itx: discord.Interaction, col: int): + await self._ensure_owner(itx) + if self._busy or self.ended: + return + if not (0 <= col < self.row_size): + return + if not await self._debit_if_needed(): + return await itx.response.send_message("๐Ÿ’ธ Not enough cash for that bet.", ephemeral=True) + + self._busy = True + try: + bads = self.layout[self.row_index] + if col in bads: + self.failed_at = (self.row_index, col) + self.ended = True + db.record_towers(self.user_id, False) + return await self._update(itx) + + self.picked_cols.append(col) + self.row_index += 1 + if self.row_index >= TIERS: + winnings = self.potential_cashout() + if winnings > 0: + db.add_cash(self.user_id, winnings) + self.ended = True + db.record_towers(self.user_id, True) + return await self._update(itx) + + await self._update(itx) + finally: + self._busy = False + + # ---------- Controls ---------- + # Row 0: bet controls (Set Bet, ร—2, ยฝ) + @discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0) + async def set_bet(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if not self._ensure_can_modify_setup(): + return await itx.response.send_message("You canโ€™t change bet after starting. Cashout or Reset.", ephemeral=True) + await itx.response.send_modal(SetBetModal(self)) + + @discord.ui.button(label="ร—2", style=discord.ButtonStyle.secondary, row=0) + async def bet_x2(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if not self._ensure_can_modify_setup(): + return await itx.response.send_message("You canโ€™t change bet after starting. Cashout or Reset.", ephemeral=True) + self.bet = max(TOWERS_MIN_BET, min(TOWERS_MAX_BET, self.bet * 2)) + await self._update(itx) + + @discord.ui.button(label="ยฝ", style=discord.ButtonStyle.secondary, row=0) + async def bet_half(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if not self._ensure_can_modify_setup(): + return await itx.response.send_message("You canโ€™t change bet after starting. Cashout or Reset.", ephemeral=True) + self.bet = max(TOWERS_MIN_BET, self.bet // 2) + await self._update(itx) + + # Row 1: difficulty select (1 component) + @discord.ui.select( + placeholder="Difficulty", + options=[ + discord.SelectOption(label="Easy", description="4 slots / 1 bad"), + discord.SelectOption(label="Medium", description="3 slots / 1 bad"), + discord.SelectOption(label="Hard", description="2 slots / 1 bad"), + discord.SelectOption(label="Expert", description="3 slots / 2 bad"), + discord.SelectOption(label="Master", description="4 slots / 3 bad"), + ], + row=1, min_values=1, max_values=1 + ) + async def diff_select(self, itx: discord.Interaction, select: discord.ui.Select): + await self._ensure_owner(itx) + if not self._ensure_can_modify_setup(): + return await itx.response.send_message("You canโ€™t change difficulty after starting. Cashout or Reset.", ephemeral=True) + self.difficulty = select.values[0] + self.reset_round() + await self._update(itx) + + # Row 2: Random / Cashout / Reset + @discord.ui.button(label="Pick Random Tile", style=discord.ButtonStyle.primary, emoji="๐ŸŽฒ", row=2) + async def pick_random(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if self.ended: return + await self._pick_col(itx, random.randrange(self.row_size)) + + @discord.ui.button(label="Cashout", style=discord.ButtonStyle.success, emoji="๐Ÿ’ฐ", row=2) + async def cashout(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + if not self.can_cashout: + return await itx.response.send_message("Nothing to cash out yet.", ephemeral=True) + self.ended = True + winnings = self.potential_cashout() + if winnings > 0: + db.add_cash(self.user_id, winnings) + db.record_game(self.user_id, True) + await self._update(itx) + + @discord.ui.button(label="Reset", style=discord.ButtonStyle.secondary, emoji="๐Ÿ”„", row=2) + async def reset_btn(self, itx: discord.Interaction, _): + await self._ensure_owner(itx) + self.reset_round() + await self._update(itx) + + # Row 3: column pick buttons (up to 4) + @discord.ui.button(label="1", style=discord.ButtonStyle.secondary, row=3) + async def col1(self, itx: discord.Interaction, _): await self._pick_col(itx, 0) + + @discord.ui.button(label="2", style=discord.ButtonStyle.secondary, row=3) + async def col2(self, itx: discord.Interaction, _): await self._pick_col(itx, 1) + + @discord.ui.button(label="3", style=discord.ButtonStyle.secondary, row=3) + async def col3(self, itx: discord.Interaction, _): await self._pick_col(itx, 2) + + @discord.ui.button(label="4", style=discord.ButtonStyle.secondary, row=3) + async def col4(self, itx: discord.Interaction, _): await self._pick_col(itx, 3) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + # keep column buttons and cashout state in sync each interaction + size = self.row_size + col_buttons: List[discord.ui.Button] = [c for c in self.children if isinstance(c, discord.ui.Button) and c.row == 3] + for i, btn in enumerate(col_buttons): + btn.disabled = self.ended or (i >= size) or (self.row_index >= TIERS and not self.failed_at) + for btn in self.children: + if isinstance(btn, discord.ui.Button) and btn.label == "Cashout": + btn.disabled = not self.can_cashout + return True + +class Towers(commands.Cog): + def __init__(self, bot): self.bot = bot + + @commands.command(name="towers") + async def towers(self, ctx: commands.Context): + uid = ctx.author.id + if uid in _active_tower_sessions: + return await ctx.send("โŒ You already have an active Towers session. Wait for it to time out or Reset.") + try: + view = TowersView(uid, timeout=180) + msg = await ctx.send(embed=view.embed(), view=view) + except Exception as e: + return await ctx.send(f"โš ๏ธ Towers failed to start: `{type(e).__name__}: {e}`") + view.message = msg + _active_tower_sessions[uid] = view + + @commands.command(name="rules_towers", aliases=["towers_rules"]) + async def rules_towers(self, ctx: commands.Context): + e = discord.Embed(title="๐Ÿ‰ Towers โ€” Rules", color=discord.Color.dark_teal()) + e.description = ( + "Nine tiers high. Pick one tile on each row:\n" + "โ€ข **Easy:** 4 slots / 1 bad (3 safe)\n" + "โ€ข **Medium:** 3 slots / 1 bad (2 safe)\n" + "โ€ข **Hard:** 2 slots / 1 bad (1 safe)\n" + "โ€ข **Expert:** 3 slots / 2 bad (1 safe)\n" + "โ€ข **Master:** 4 slots / 3 bad (1 safe)\n\n" + "You pay your bet once on the first pick. Each safe pick **multiplies** your cashout. " + "Cash out anytime; hit a bad tile and you lose the bet." + ) + lines = [] + for name in DIFF_ORDER: + size, bad = DIFFS[name] + lines.append(f"**{name}** per row โ‰ˆ x{step_multiplier(size, bad):.2f}") + e.add_field(name="Multipliers", value="\n".join(lines), inline=False) + await ctx.send(embed=e) + +async def setup(bot): + await bot.add_cog(Towers(bot)) + diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..18312bb --- /dev/null +++ b/src/db.py @@ -0,0 +1,340 @@ +import os, sqlite3 +from datetime import date, datetime, timedelta + +DB_PATH = os.getenv("DB_PATH", "/app/data/blackjack.db") + +def _connect(): + return sqlite3.connect(DB_PATH) + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = _connect() + cur = conn.cursor() + # base + cur.execute(""" + CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + cash INTEGER DEFAULT 1000, + games_played INTEGER DEFAULT 0, + games_won INTEGER DEFAULT 0, + last_daily DATE DEFAULT NULL + ) + """) + # add new columns if missing + 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: + cur.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}") + conn.commit() + conn.close() + +def ensure_user(user_id: int): + conn = _connect(); cur = conn.cursor() + cur.execute("SELECT 1 FROM users WHERE user_id=?", (user_id,)) + if cur.fetchone() is None: + cur.execute("INSERT INTO users (user_id) VALUES (?)", (user_id,)) + conn.commit() + conn.close() + +def get_wallet(user_id: int): + ensure_user(user_id) + conn = _connect(); cur = conn.cursor() + cur.execute("SELECT cash, free_spins FROM users WHERE user_id=?", (user_id,)) + cash, free_spins = cur.fetchone() + conn.close() + return cash, free_spins + +def add_cash(user_id: int, delta: int): + ensure_user(user_id) + conn = _connect(); cur = conn.cursor() + cur.execute("UPDATE users SET cash = cash + ? WHERE user_id=?", (delta, user_id)) + conn.commit(); conn.close() + +def add_free_spins(user_id: int, spins: int): + ensure_user(user_id) + if spins <= 0: return + conn = _connect(); cur = conn.cursor() + cur.execute("UPDATE users SET free_spins = free_spins + ? WHERE user_id=?", (spins, user_id)) + conn.commit(); conn.close() + +def consume_free_spin(user_id: int) -> bool: + ensure_user(user_id) + conn = _connect(); cur = conn.cursor() + cur.execute("SELECT free_spins FROM users WHERE user_id=?", (user_id,)) + fs = cur.fetchone()[0] + if fs > 0: + 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 + +# legacy aggregate +def record_game(user_id: int, won: bool): + ensure_user(user_id) + conn = _connect(); cur = conn.cursor() + if won: + cur.execute("UPDATE users SET games_played=games_played+1, games_won=games_won+1 WHERE user_id=?", (user_id,)) + else: + cur.execute("UPDATE users SET games_played=games_played+1 WHERE user_id=?", (user_id,)) + conn.commit(); conn.close() + +# per-game +def record_blackjack(user_id: int, won: bool): + ensure_user(user_id) + conn = _connect(); cur = conn.cursor() + if won: + cur.execute(""" + UPDATE users SET + games_played_blackjack = games_played_blackjack + 1, + games_won_blackjack = games_won_blackjack + 1 + WHERE user_id = ? + """, (user_id,)) + else: + cur.execute(""" + UPDATE users SET + games_played_blackjack = games_played_blackjack + 1 + WHERE user_id = ? + """, (user_id,)) + conn.commit(); conn.close() + record_game(user_id, won) + +def record_slots(user_id: int, win_amount: int): + ensure_user(user_id) + won = (win_amount > 0) + conn = _connect(); cur = conn.cursor() + if won: + cur.execute(""" + UPDATE users SET + games_played_slots = games_played_slots + 1, + games_won_slots = games_won_slots + 1, + slots_biggest_win = CASE WHEN ? > slots_biggest_win THEN ? ELSE slots_biggest_win END + WHERE user_id = ? + """, (win_amount, win_amount, user_id)) + else: + cur.execute(""" + UPDATE users SET + games_played_slots = games_played_slots + 1 + WHERE user_id = ? + """, (user_id,)) + conn.commit(); conn.close() + record_game(user_id, won) + +def record_roulette(user_id: int, total_bet: int, total_return: int): + ensure_user(user_id) + net = total_return - total_bet + conn = _connect(); cur = conn.cursor() + cur.execute(""" + UPDATE users + SET games_played_roulette = games_played_roulette + 1, + roulette_net = roulette_net + ?, + roulette_biggest_win = CASE WHEN ? > roulette_biggest_win THEN ? ELSE roulette_biggest_win END + WHERE user_id = ? + """, (net, total_return, total_return, user_id)) + conn.commit(); conn.close() + +def record_coinflip(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_coinflip = games_played_coinflip + 1, + games_won_coinflip = games_won_coinflip + 1, + coinflip_net = coinflip_net + ?, + coinflip_biggest_win = CASE WHEN ? > coinflip_biggest_win THEN ? ELSE coinflip_biggest_win END + WHERE user_id = ? + """, (net, return_amount, return_amount, user_id)) + else: + cur.execute(""" + UPDATE users + SET games_played_coinflip = games_played_coinflip + 1, + coinflip_net = coinflip_net + ? + WHERE user_id = ? + """, (net, user_id)) + conn.commit(); conn.close() + 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: + cur.execute(""" + UPDATE users SET + games_played_towers = games_played_towers + 1, + games_won_towers = games_won_towers + 1 + WHERE user_id = ? + """, (user_id,)) + else: + cur.execute(""" + UPDATE users SET + games_played_towers = games_played_towers + 1 + WHERE user_id = ? + """, (user_id,)) + 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 + conn = _connect(); cur = conn.cursor() + cur.execute(""" + UPDATE users + SET games_played_baccarat = games_played_baccarat + 1, + games_won_baccarat = games_won_baccarat + CASE WHEN ? > 0 THEN 1 ELSE 0 END, + baccarat_net = baccarat_net + ?, + baccarat_biggest_win = CASE WHEN ? > baccarat_biggest_win THEN ? ELSE baccarat_biggest_win END + WHERE user_id = ? + """, (net, net, total_return, total_return, user_id)) + conn.commit(); conn.close() + record_game(user_id, won) + +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() + return rows + +def user_counts(): + conn = _connect(); cur = conn.cursor() + 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 + 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", + ] + return dict(zip(keys, row)) + +def can_claim_topup(user_id: int, cooldown_minutes: int = 5) -> tuple[bool, int]: + ensure_user(user_id) + conn = _connect(); cur = conn.cursor() + cur.execute("SELECT last_topup FROM users WHERE user_id=?", (user_id,)) + row = cur.fetchone(); conn.close() + last = row[0] if row else None + 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) + if now - last_dt >= wait: return True, 0 + left = int((wait - (now - last_dt)).total_seconds()) + return False, left + +def claim_topup(user_id: int, amount: int): + ensure_user(user_id) + now_str = datetime.utcnow().isoformat(timespec="seconds") + conn = _connect(); cur = conn.cursor() + cur.execute("UPDATE users SET cash = cash + ?, last_topup = ? WHERE user_id = ?", (amount, now_str, user_id)) + conn.commit(); conn.close() + +def daily_cooldown(user_id: int, hours: int = 24) -> tuple[bool, int]: + ensure_user(user_id) + conn = _connect(); cur = conn.cursor() + cur.execute("SELECT last_daily_ts FROM users WHERE user_id=?", (user_id,)) + row = cur.fetchone(); conn.close() + last = row[0] if row else None + if not last: return True, 0 + try: last_dt = datetime.fromisoformat(last) + except Exception: return True, 0 + 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") + conn = _connect(); cur = conn.cursor() + cur.execute(""" + UPDATE users + SET cash = cash + ?, free_spins = free_spins + ?, last_daily = ?, last_daily_ts = ? + WHERE user_id = ? + """, (cash_bonus, spin_bonus, today, now_str, user_id)) + conn.commit(); conn.close() + +def transfer_cash(from_user_id: int, to_user_id: int, amount: int): + ensure_user(from_user_id); ensure_user(to_user_id) + if amount is None or amount <= 0: return False, "INVALID_AMOUNT", None, None + if from_user_id == to_user_id: return False, "SAME_USER", None, None + conn = _connect(); cur = conn.cursor() + try: + cur.execute("BEGIN IMMEDIATE") + cur.execute("SELECT cash FROM users WHERE user_id=?", (from_user_id,)) + row = cur.fetchone() + if not row: conn.rollback(); return False, "INSUFFICIENT_FUNDS", None, None + from_cash = row[0] + if from_cash < amount: conn.rollback(); return False, "INSUFFICIENT_FUNDS", from_cash, None + cur.execute("UPDATE users SET cash = cash - ? WHERE user_id=?", (amount, from_user_id)) + cur.execute("UPDATE users SET cash = cash + ? WHERE user_id=?", (amount, to_user_id)) + cur.execute("SELECT cash FROM users WHERE user_id=?", (from_user_id,)); new_from = cur.fetchone()[0] + cur.execute("SELECT cash FROM users WHERE user_id=?", (to_user_id,)); new_to = cur.fetchone()[0] + conn.commit(); return True, None, new_from, new_to + except Exception: + conn.rollback(); raise + finally: + conn.close() + diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/constants.py b/src/utils/constants.py new file mode 100644 index 0000000..7ae9ccc --- /dev/null +++ b/src/utils/constants.py @@ -0,0 +1,67 @@ +import os + +# Daily rewards +DAILY_CASH = int(os.getenv("DAILY_BONUS", "10000")) +DAILY_COOLDOWN_HOURS = int(os.getenv("DAILY_COOLDOWN_HOURS", "24")) +DAILY_FREE_SPINS = int(os.getenv("DAILY_FREE_SPINS", "10")) + +# Slots base (now we support variable bets; this acts as default if user omits) +SLOTS_DEFAULT_BET = int(os.getenv("SLOTS_DEFAULT_BET", "8")) +SLOTS_MIN_BET = int(os.getenv("SLOTS_MIN_BET", "8")) +SLOTS_MAX_BET = int(os.getenv("SLOTS_MAX_BET", "5000")) + +# Symbols and payouts (multiplier for three-in-a-line) +SYMBOLS = ["๐Ÿ’", "๐Ÿ‹", "๐Ÿ‡", "๐Ÿ‰", "๐ŸŠ", "โญ", "๐Ÿ’Ž"] +WEIGHTS = [6, 5, 4, 3, 3, 1, 1] # ๐Ÿ’ ๐Ÿ‹ ๐Ÿ‡ ๐Ÿ‰ ๐ŸŠ โญ ๐Ÿ’Ž + +# Retuned payouts (pure 3-of-a-kind) +PAYOUTS = {"๐Ÿ’":6, "๐Ÿ‹":12, "๐Ÿ‡":18, "๐Ÿ‰":26, "๐ŸŠ":40, "โญ":2200, "๐Ÿ’Ž":2800} + +# Wildcard nerf +WILDCARD_FACTOR = float(os.getenv("WILDCARD_FACTOR", "0.45")) + +# Special combo: โญโญโญ on the middle row awards free spins +SPECIAL_SYMBOL = "โญ" +SPECIAL_BONUS_SPINS = int(os.getenv("SLOTS_SPECIAL_BONUS_SPINS", "15")) + +# Top-up faucet +TOPUP_AMOUNT = int(os.getenv("TOPUP_AMOUNT", "100")) +TOPUP_COOLDOWN_MINUTES = int(os.getenv("TOPUP_COOLDOWN_MINUTES", "5")) + +# --- Roulette constants (Mini wheel: 0โ€“18) --- + +ROULETTE_MIN_CHIP = 10 +ROULETTE_MIN_BET = 10 +ROULETTE_MAX_BET = 100000 + +# Mini wheel pockets +ROULETTE_NUMBERS = list(range(19)) # 0..18 + +# Colors on the mini layout (subset of the standard pattern) +ROULETTE_RED = {1,3,5,7,9,12,14,16,18} +ROULETTE_BLACK = set(n for n in range(1, 19) if n not in ROULETTE_RED) + +# Mini payouts (N:1). These correspond to (18/K - 1):1 where K is numbers covered. +ROULETTE_PAYOUTS = { + "straight": 17, # K=1 + "split": 8, # K=2 + "street": 5, # K=3 (incl. 0-1-2 and 0-2-3) + "sixline": 2, # K=6 + "column": 2, # K=6 + "sixes": 2, # groups 1โ€“6 / 7โ€“12 / 13โ€“18 (K=6) + "redblack": 1, # K=9 (0 loses) + "evenodd": 1, # K=9 (0 loses) + "lowhigh": 1, # K=9 (0 loses) +} + +# --- Coin Flip constants --- + +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_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)) +