Initial commit: Casino bot
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -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
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
.env.local
|
||||
# OS
|
||||
.DS_Store
|
||||
# Project
|
||||
data/
|
||||
*.db
|
||||
backups/
|
||||
# Docker
|
||||
*.log
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -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"]
|
||||
|
||||
7
README.md
Normal file
7
README.md
Normal file
@@ -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
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -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" }
|
||||
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
discord.py==2.3.2
|
||||
aiohttp==3.9.1
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
35
src/bot.py
Normal file
35
src/bot.py
Normal file
@@ -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())
|
||||
|
||||
0
src/cogs/__init__.py
Normal file
0
src/cogs/__init__.py
Normal file
337
src/cogs/baccarat.py
Normal file
337
src/cogs/baccarat.py
Normal file
@@ -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))
|
||||
|
||||
439
src/cogs/blackjack.py
Normal file
439
src/cogs/blackjack.py
Normal file
@@ -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))
|
||||
|
||||
196
src/cogs/coinflip.py
Normal file
196
src/cogs/coinflip.py
Normal file
@@ -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))
|
||||
|
||||
325
src/cogs/economy.py
Normal file
325
src/cogs/economy.py
Normal file
@@ -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 <bet>` (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))
|
||||
|
||||
590
src/cogs/roulette.py
Normal file
590
src/cogs/roulette.py
Normal file
@@ -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))
|
||||
|
||||
340
src/cogs/slots.py
Normal file
340
src/cogs/slots.py
Normal file
@@ -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))
|
||||
|
||||
372
src/cogs/towers.py
Normal file
372
src/cogs/towers.py
Normal file
@@ -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))
|
||||
|
||||
340
src/db.py
Normal file
340
src/db.py
Normal file
@@ -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()
|
||||
|
||||
0
src/utils/__init__.py
Normal file
0
src/utils/__init__.py
Normal file
67
src/utils/constants.py
Normal file
67
src/utils/constants.py
Normal file
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user