add hilo, mines, and packs

This commit is contained in:
2025-08-29 11:50:38 -05:00
parent db821a14b4
commit 000b119641
8 changed files with 1659 additions and 122 deletions

View File

@@ -28,6 +28,9 @@ async def main():
await bot.load_extension("src.cogs.coinflip")
await bot.load_extension("src.cogs.towers")
await bot.load_extension("src.cogs.baccarat")
await bot.load_extension("src.cogs.hilo")
await bot.load_extension("src.cogs.mines")
await bot.load_extension("src.cogs.packs")
await bot.start(TOKEN)
if __name__ == "__main__":

View File

@@ -265,7 +265,7 @@ class BlackjackPanel(discord.ui.View):
e = discord.Embed(title="🎲 Blackjack", color=discord.Color.blurple())
e.description = (
"Set your **bet**, then press **Deal** to start.\n"
"During a hand use **Hit / Stand / Double / Split**.\n"
"During a hand use **Hit / Stand / Double / Split**.\n\n"
f"**Current bet:** ${self.bet}\n"
)
e.add_field(name="Balance", value=f"${cash:,}", inline=True)

View File

@@ -1,15 +1,36 @@
import discord
from discord.ext import commands
from .. import db
from ..utils.constants import (
# Try to import constants; provide safe defaults if missing.
try:
from ..utils.constants import (
DAILY_CASH, DAILY_FREE_SPINS,
PAYOUTS, SPECIAL_SYMBOL, SPECIAL_BONUS_SPINS,
SLOTS_MIN_BET, SLOTS_MAX_BET, WILDCARD_FACTOR,
TOPUP_AMOUNT, TOPUP_COOLDOWN_MINUTES, DAILY_COOLDOWN_HOURS,
COINFLIP_MIN_CHIP, COINFLIP_MIN_BET, COINFLIP_MAX_BET, TOWERS_EDGE_PER_STEP,
)
COINFLIP_MIN_CHIP, COINFLIP_MIN_BET, COINFLIP_MAX_BET,
TOWERS_EDGE_PER_STEP, PACK_SIZE
)
except Exception:
DAILY_CASH = 10_000
DAILY_FREE_SPINS = 0
PAYOUTS = {}
SPECIAL_SYMBOL = ""
SPECIAL_BONUS_SPINS = 5
SLOTS_MIN_BET = 10
SLOTS_MAX_BET = 5000
WILDCARD_FACTOR = 0.8
TOPUP_AMOUNT = 100
TOPUP_COOLDOWN_MINUTES = 5
DAILY_COOLDOWN_HOURS = 24
COINFLIP_MIN_CHIP = 10
COINFLIP_MIN_BET = 10
COINFLIP_MAX_BET = 5000
TOWERS_EDGE_PER_STEP = 0.015
PACK_SIZE = 5
# ---------------- Rules UI (unchanged) ----------------
# ---------------- Rules UI ----------------
class RulesView(discord.ui.View):
def __init__(self):
super().__init__(timeout=60)
@@ -24,6 +45,9 @@ class RulesSelect(discord.ui.Select):
discord.SelectOption(label="Coin Flip", emoji="🪙", description="How to play Coin Flip"),
discord.SelectOption(label="Towers", emoji="🐉", description="How to play Towers"),
discord.SelectOption(label="Baccarat", emoji="🎴", description="How to play Baccarat"),
discord.SelectOption(label="Hi-Lo", emoji="🔼", description="How to play Hi-Lo"),
discord.SelectOption(label="Mines", emoji="💣", description="Pick safe tiles, avoid bombs"),
discord.SelectOption(label="Packs", emoji="📦", description="Open 5-item packs & build a collection"),
]
super().__init__(placeholder="Choose a game…", options=options, min_values=1, max_values=1)
@@ -39,20 +63,46 @@ class RulesSelect(discord.ui.Select):
embed = rules_towers_embed()
elif choice == "Baccarat":
embed = rules_baccarat_embed()
else: # Coin Flip
embed = rules_coin_embed()
elif choice == "Hi-Lo":
embed = rules_hilo_embed()
elif choice == "Mines":
embed = rules_mines_embed()
else: # Packs or fallback
embed = rules_packs_embed()
await interaction.response.edit_message(embed=embed, view=self.view)
# ---- Rules embeds ----
def rules_mines_embed():
e = discord.Embed(title="💣 Mines — Rules", color=discord.Color.dark_green())
e.description = (
"A 5×5 board hides a set number of **mines** (you choose 1, 3, 5, or 7).\n"
"Start a round (bet is debited), then pick cells. Each **safe** pick grows your cashout; "
"**Cash Out** anytime. Hitting a mine loses the bet.\n\n"
"Each safe pick multiplies by roughly `total_remaining / safe_remaining` minus a small house edge."
)
return e
def rules_hilo_embed():
e = discord.Embed(title="🔼 Hi-Lo — Rules", color=discord.Color.dark_green())
e.description = (
"Guess whether the **next card** will be **Higher** or **Lower**.\n"
"• Ties **push** (no change)\n"
"• Each correct guess multiplies your cashout; **Cash Out** anytime\n"
"• Edge per step: about 2% (tunable)\n\n"
"From low cards, **Higher** pays less; from high cards, **Lower** pays less. "
"From extremes (A/K) the safe side pays ~×1."
)
return e
def rules_baccarat_embed():
e = discord.Embed(title="🎴 Baccarat — Rules", color=discord.Color.dark_green())
e.description = (
"**Bets:** Player 1:1 • Banker 1:1 (5% commission) • Tie 8:1 (Player/Banker push on tie)\n"
"Cards: A=1, 10/J/Q/K=0. Totals are modulo 10.\n"
"Naturals (8/9) stand; otherwise Player/Banker draw by standard third-card rules."
"Cards: A=1, 10/J/Q/K=0 (mod 10 totals). Naturals (8/9) stand; otherwise standard third-card rules apply."
)
return e
def rules_towers_embed():
e = discord.Embed(title="🐉 Towers — Rules", color=discord.Color.dark_teal())
e.description = (
@@ -62,8 +112,7 @@ def rules_towers_embed():
"• **Hard:** 2 / 1\n"
"• **Expert:** 3 / 2\n"
"• **Master:** 4 / 3\n\n"
"You pay your bet once on the first pick. Each safe pick multiplies your cashout; "
f"a small house edge (~{int(TOWERS_EDGE_PER_STEP*100)}% per step) is applied. "
f"You pay your bet once on the first pick. Each safe pick multiplies your cashout (edge ~{int(TOWERS_EDGE_PER_STEP*100)}% per step). "
"Cash out anytime; hit a bad tile and you lose the bet."
)
return e
@@ -71,9 +120,9 @@ def rules_towers_embed():
def rules_coin_embed():
e = discord.Embed(title="🪙 Coin Flip — Rules", color=discord.Color.blurple())
e.description = (
"Pick **Heads** or **Tails**, set your **Chip**, and flip.\n"
"**Payout:** 1:1 • "
f"**Limits:** min chip ${COINFLIP_MIN_CHIP}, per flip ${COINFLIP_MIN_BET}${COINFLIP_MAX_BET}"
"Pick **Heads** or **Tails**, set your **Bet**, and flip.\n"
f"**Payout:** 1:1 • **Limits:** per flip ${COINFLIP_MIN_BET}${COINFLIP_MAX_BET} "
f"(min chip ${COINFLIP_MIN_CHIP})."
)
return e
@@ -82,24 +131,28 @@ def rules_blackjack_embed():
e.description = (
"**Objective:** Get as close to 21 as possible without going over.\n\n"
"**Card Values:** Numbers=face value, J/Q/K=10, A=11 or 1.\n\n"
"**Actions:** Hit, Stand, Double Down (first two cards), Split (matching ranks/10-values).\n"
"**Actions:** Hit, Stand, Double Down (first two cards), Split (matching ranks/10-values). "
"Dealer hits to 16, stands on 17.\n\n"
"**Payouts:** Win=1:1, Blackjack=3:2, Push=bet returned."
)
return e
def rules_slots_embed():
# Payout table (if provided) to pretty-print
if PAYOUTS:
pairs = sorted(PAYOUTS.items(), key=lambda kv: kv[1], reverse=True)
lines = [f"{sym} x{mult}" for sym, mult in pairs]
table = "```\nSymbol Multiplier\n" + "\n".join(lines) + "\n```"
else:
table = ""
e = discord.Embed(title="🎰 Fruit Slots — Rules", color=discord.Color.purple())
e.description = (
"**Board:** 3×3 grid. Wins on rows, columns, and diagonals.\n"
f"**Bet:** `!slots <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"
f"**Bet:** set on the panel (min ${SLOTS_MIN_BET}, max ${SLOTS_MAX_BET}).\n"
"**Paylines:** Choose **1 / 3 / 5 / 8** lines. Your **spin bet** is split evenly across active lines.\n"
"• Example: Bet $80 on 8 lines → $10 per line; a line paying x9 returns $90.\n\n"
f"**Wildcard:** ⭐ counts as any fruit only for lines with **exactly two of the same fruit + one ⭐**; pays **{int(WILDCARD_FACTOR*100)}%** of that fruit's multiplier.\n"
f"**Wildcard:** ⭐ acts as any fruit only for lines with **exactly two of the same fruit + one ⭐**; pays **{int(WILDCARD_FACTOR*100)}%** of that fruit's multiplier.\n"
f"**Bonus:** {SPECIAL_SYMBOL}{SPECIAL_SYMBOL}{SPECIAL_SYMBOL} on the **middle row** awards **{SPECIAL_BONUS_SPINS} free spins** (only if that line is active).\n"
"**Payouts:** Three-in-a-row multipliers (applied per winning line × per-line bet)."
)
@@ -113,11 +166,22 @@ def rules_roulette_mini_embed():
"**Outside Bets:** Red/Black 1:1 • Even/Odd 1:1 (0 loses) • Low/High 1:1 • "
"Column (6 nums) 2:1 • Sixes (16 / 712 / 1318) 2:1\n"
"**Inside Bets:** Straight 17:1 • Split 8:1 • Street 5:1 • Six-line 2:1\n\n"
"Use **Add Outside/Inside** to build the slip, set **Chip**, then **Spin** or **Spin Again**."
"Use the panel to build bets, set **Bet**, then **Spin** / **Spin Again**."
)
return e
# ---------------- Casino menu ----------------
def rules_packs_embed():
e = discord.Embed(title="📦 Packs — Rules", color=discord.Color.dark_gold())
e.description = (
f"Open a pack of **{PACK_SIZE}** items. Each item has a **rarity** and a **payout multiplier**.\n"
"Your return is the **sum of multipliers × your bet**. New items are added to your **collection**.\n\n"
"**Rarities:** Common, Uncommon, Rare, Epic, Legendary, Mythic (rarest).\n"
"Use **Set Bet**, **×2**, **½**, then **Open Pack**. Use **Open Again** to repeat quickly.\n"
"Click **Collection** on the panel to see progress."
)
return e
# ---------------- Casino launcher ----------------
class CasinoView(discord.ui.View):
"""Buttons to launch any game for the invoking user."""
def __init__(self, cog: "Economy", ctx: commands.Context):
@@ -133,17 +197,16 @@ class CasinoView(discord.ui.View):
return True
async def _launch(self, interaction: discord.Interaction, command_name: str):
# Acknowledge the click fast so Discord doesn't show "interaction failed"
# Acknowledge fast
try:
await interaction.response.defer(thinking=False)
except Exception:
pass
# Delete the casino menu message to avoid clutter
# Delete the menu to reduce clutter
try:
await interaction.message.delete()
except Exception:
# Fallback: disable the view & mark as launching
for item in self.children:
item.disabled = True
try:
@@ -151,32 +214,40 @@ class CasinoView(discord.ui.View):
except Exception:
pass
# Now invoke the actual game command
# Invoke command
cmd = self.cog.bot.get_command(command_name)
if cmd is None:
return await self.ctx.send(f"⚠️ Command `{command_name}` not found.")
await self.ctx.invoke(cmd)
@discord.ui.button(label="Blackjack", style=discord.ButtonStyle.success, emoji="🃏", row=0)
async def bj(self, itx: discord.Interaction, _): await self._launch(itx, "blackjack")
@discord.ui.button(label="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)
@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)
@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)
@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)
@discord.ui.button(label="Baccarat", style=discord.ButtonStyle.secondary,emoji="🎴", row=2)
async def baccarat(self, itx: discord.Interaction, _): await self._launch(itx, "baccarat")
@discord.ui.button(label="Hi-Lo", style=discord.ButtonStyle.secondary,emoji="🔼", row=2)
async def hilo(self, itx: discord.Interaction, _): await self._launch(itx, "hilo")
@discord.ui.button(label="Mines", style=discord.ButtonStyle.secondary,emoji="💣", row=2)
async def mines(self, itx: discord.Interaction, _): await self._launch(itx, "mines")
@discord.ui.button(label="Packs", style=discord.ButtonStyle.secondary,emoji="📦", row=3)
async def packs(self, itx: discord.Interaction, _): await self._launch(itx, "packs")
# ---------------- Economy cog ----------------
class Economy(commands.Cog):
def __init__(self, bot):
self.bot = bot
@@ -222,16 +293,21 @@ class Economy(commands.Cog):
if target.avatar:
embed.set_thumbnail(url=target.avatar.url)
# Topline economy
# Topline
embed.add_field(name="Cash", value=f"${s['cash']}", inline=True)
embed.add_field(name="Free Spins", value=f"{s['free_spins']}", inline=True)
embed.add_field(name="Overall W/L", value=f"{s['games_won']}/{s['games_played']} ({pct(s['games_won'], s['games_played'])})", inline=True)
# Per-game
embed.add_field(name="🃏 Blackjack", value=f"W/L: **{s['gw_bj']}/{s['gp_bj']}** ({pct(s['gw_bj'], s['gp_bj'])})", inline=True)
embed.add_field(name="🎰 Slots", value=f"W/L: **{s['gw_slots']}/{s['gp_slots']}** ({pct(s['gw_slots'], s['gp_slots'])})", inline=True)
embed.add_field(name="🎯 Roulette", value=f"Spins: **{s['gp_roulette']}** • Net: **{'+' if s['roulette_net']>=0 else ''}${s['roulette_net']}**", inline=True)
embed.add_field(name="🪙 Coin Flip", value=f"W/L: **{s['gw_coin']}/{s['gp_coin']}** ({pct(s['gw_coin'], s['gp_coin'])})", inline=True)
embed.add_field(name="🐉 Towers", value=f"W/L: **{s['gw_towers']}/{s['gp_towers']}** ({pct(s['gw_towers'], s['gp_towers'])})", inline=True)
embed.add_field(name="🎴 Baccarat", value=f"Hands: **{s['gp_bac']}** • Net: **{'+' if s['bac_net']>=0 else ''}${s['bac_net']}**", inline=True)
embed.add_field(name="🔼 Hi-Lo", value=f"Rounds: **{s['gp_hilo']}** • Net: **{'+' if s['hilo_net']>=0 else ''}${s['hilo_net']}**", inline=True)
embed.add_field(name="💣 Mines", value=f"Rounds: **{s['gp_mines']}** • Net: **{'+' if s['mines_net']>=0 else ''}${s['mines_net']}**", inline=True)
embed.add_field(name="📦 Packs", value=f"Opened: **{s['gp_packs']}** • Net: **{'+' if s['packs_net']>=0 else ''}${s['packs_net']}**", inline=True)
await ctx.send(embed=embed)
@@ -241,14 +317,13 @@ class Economy(commands.Cog):
can, secs_left = db.daily_cooldown(user_id, DAILY_COOLDOWN_HOURS)
if not can:
h = secs_left // 3600; m = (secs_left % 3600) // 60; s = secs_left % 60
await ctx.send(f"⏳ You can claim again in **{h}h {m}m {s}s**.")
return
return await ctx.send(f"⏳ You can claim again in **{h}h {m}m {s}s**.")
db.claim_daily(user_id, DAILY_CASH, DAILY_FREE_SPINS)
stats = db.get_full_stats(user_id)
embed = discord.Embed(
title="🎁 Daily Bonus!",
description=(f"You received **${DAILY_CASH}** and **{DAILY_FREE_SPINS} free spins**!\n"
f"New balance: **${stats['cash']}**, Free spins: **{stats['free_spins']}**"),
f"New balance: **${stats['cash']}**, Free spins: **${stats['free_spins']}**"),
color=discord.Color.green()
)
await ctx.send(embed=embed)
@@ -256,7 +331,8 @@ class Economy(commands.Cog):
@commands.command(name="leaderboard", aliases=["lb"])
async def leaderboard(self, ctx):
rows = db.top_cash(10)
if not rows: return await ctx.send("No players found!")
if not rows:
return await ctx.send("No players found!")
lines = []
for i, (uid, cash) in enumerate(rows, 1):
name = await self.resolve_name(ctx, uid)
@@ -311,6 +387,7 @@ class Economy(commands.Cog):
if err == "INSUFFICIENT_FUNDS": return await ctx.send(f"💸 Not enough cash. Your balance: ${from_cash}")
if err == "SAME_USER": return await ctx.send("🪞 You cant tip yourself.")
return await ctx.send("❌ Couldnt complete the tip. Try again in a moment.")
# names
sender = await self.resolve_name(ctx, ctx.author.id)
recv = await self.resolve_name(ctx, member.id)
desc = f"**{sender}** → **{recv}**\nAmount: **${amt:,}**"
@@ -322,4 +399,3 @@ class Economy(commands.Cog):
async def setup(bot):
await bot.add_cog(Economy(bot))

357
src/cogs/hilo.py Normal file
View File

@@ -0,0 +1,357 @@
# src/cogs/hilo.py
import random
import discord
from discord.ext import commands
from typing import Optional
from .. import db
# ---- Config (safe fallbacks; override in constants.py if you like) ----
try:
from ..utils.constants import HILO_EDGE_PER_STEP, HILO_MAX_MULT, HILO_MIN_BET
except Exception:
HILO_EDGE_PER_STEP = 0.02 # 2% house edge per correct step
HILO_MAX_MULT = 50.0 # cap total multiplier
HILO_MIN_BET = 10
SUITS = ["♠️","♥️","♦️","♣️"]
RANKS = ["A","2","3","4","5","6","7","8","9","10","J","Q","K"]
RANK_TO_VAL = {r:i+1 for i,r in enumerate(RANKS)} # A=1 ... K=13
def draw_card():
return random.choice(RANKS), random.choice(SUITS)
def step_multiplier(cur_rank_val: int, direction: str) -> float:
"""
Ties are 'push' (no change), so effective trial space ignores equals.
Wins among 12 non-equal ranks.
- Higher: wins = 13 - cur
- Lower: wins = cur - 1
Fair step mult = 12 / wins. Apply house edge. Clamp at 1.0 for guaranteed steps.
"""
if direction == "up":
wins = 13 - cur_rank_val
else:
wins = cur_rank_val - 1
if wins <= 0:
return 1.0
fair = 12.0 / wins
mult = fair * (1.0 - HILO_EDGE_PER_STEP)
return max(1.0, mult)
def card_str(rank: str, suit: str) -> str:
return f"{rank}{suit}"
def fmt_money(n: int) -> str:
return f"${n:,}"
class SetBetModal(discord.ui.Modal, title="Set Hi-Lo Bet"):
def __init__(self, view: "HiloView"):
super().__init__()
self.view = view
self.amount = discord.ui.TextInput(
label=f"Amount (min {HILO_MIN_BET})",
placeholder=str(self.view.bet),
required=True, min_length=1, max_length=10,
)
self.add_item(self.amount)
async def on_submit(self, itx: discord.Interaction):
if itx.user.id != self.view.user_id:
return await itx.response.send_message("This isnt your Hi-Lo panel.", ephemeral=True)
if self.view.in_round:
return await itx.response.send_message("You cant change bet during a round.", ephemeral=True)
raw = str(self.amount.value)
try:
amt = int("".join(ch for ch in raw if ch.isdigit()))
except Exception:
return await itx.response.send_message("Enter a valid number.", ephemeral=True)
self.view.bet = max(HILO_MIN_BET, amt)
await itx.response.defer(ephemeral=True)
if self.view.message:
await self.view.message.edit(embed=self.view.render(), view=self.view)
class HiloView(discord.ui.View):
"""
Row 0: Set Bet · ×2 · ½ · Start
Row 1: LOWER · Cash Out · HIGHER
"""
def __init__(self, user_id: int, initial_bet: int = 50, *, timeout: int = 180):
super().__init__(timeout=timeout)
self.user_id = user_id
self.bet = max(HILO_MIN_BET, initial_bet)
# round state
self.in_round = False
self.current_card: Optional[tuple[str,str]] = None
self.mult: float = 1.0
self.steps: int = 0
self.last_note: Optional[str] = None
self.message: Optional[discord.Message] = None
# NEW: summary of the last finished round (loss or cashout)
# keys: 'from','to','guess','net','returned','steps','mult'
self.last_summary: Optional[dict] = None
self._busy = False
self._sync_buttons()
def disable_all(self):
for c in self.children:
c.disabled = True
async def on_timeout(self):
self.disable_all()
try:
if self.message: await self.message.edit(view=self)
except: pass
# ----- rendering -----
def render(self) -> discord.Embed:
cash, _ = db.get_wallet(self.user_id)
# Pick color based on state / last result
color = discord.Color.blurple()
if self.in_round:
color = discord.Color.green()
elif self.last_summary is not None:
net = self.last_summary.get("net", 0)
if net > 0: color = discord.Color.green()
elif net < 0: color = discord.Color.red()
else: color = discord.Color.orange()
e = discord.Embed(title="🔼 Hi-Lo", color=color)
if not self.in_round or not self.current_card:
desc = [
"Set your **bet**, press **Start**, then guess **Higher** or **Lower**.",
"Ties **push** (no gain/loss). **Cash Out** anytime.",
"",
f"**Current bet:** {fmt_money(self.bet)}",
f"**Balance:** {fmt_money(cash)}",
]
# Show last round summary if available
if self.last_summary is not None:
sym = "💰" if self.last_summary["net"] > 0 else ("" if self.last_summary["net"] < 0 else "↔️")
guess = self.last_summary.get("guess", "")
fromc = self.last_summary.get("from")
toc = self.last_summary.get("to")
steps = self.last_summary.get("steps", 0)
mult = self.last_summary.get("mult", 1.0)
returned = self.last_summary.get("returned", 0)
net = self.last_summary.get("net", 0)
line1 = f"**Last Result:** {sym} " \
f"{'Cashed out' if guess=='CASHOUT' else f'Guess {guess}'}"
if fromc and toc:
line1 += f"{card_str(*fromc)}{card_str(*toc)}"
elif fromc:
line1 += f" — start {card_str(*fromc)}"
line2 = f"Steps: **{steps}**, Mult: **×{mult:.2f}**, " \
f"Returned: **{fmt_money(returned)}**, Net: **{'+' if net>0 else ''}{fmt_money(net)}**"
desc += ["", line1, line2]
e.description = "\n".join(desc)
return e
# In-round panel
rank, suit = self.current_card
cur_val = RANK_TO_VAL[rank]
up_mult = step_multiplier(cur_val, "up")
dn_mult = step_multiplier(cur_val, "down")
e.add_field(name="Current Card", value=f"**{card_str(rank, suit)}**", inline=True)
e.add_field(name="Steps", value=str(self.steps), inline=True)
e.add_field(name="Multiplier", value=f"x{self.mult:.2f}", inline=True)
e.add_field(name="Potential Cashout", value=fmt_money(int(self.bet * self.mult)), inline=True)
e.add_field(name="Next Step (Higher)", value=f"×{up_mult:.2f}", inline=True)
e.add_field(name="Next Step (Lower)", value=f"×{dn_mult:.2f}", inline=True)
if self.last_note:
e.set_footer(text=self.last_note)
return e
# ----- helpers -----
def _sync_buttons(self):
hand_active = self.in_round and self.current_card is not None
# Row 0
for lbl in ("Set Bet","×2","½","Start"):
b = self._btn(lbl)
if b: b.disabled = hand_active
# Row 1
for lbl in ("LOWER","Cash Out","HIGHER"):
b = self._btn(lbl)
if b: b.disabled = not hand_active
# Edge cases: disable impossible guesses
if hand_active and self.current_card:
cur = RANK_TO_VAL[self.current_card[0]]
if self._btn("LOWER"):
self._btn("LOWER").disabled = self._btn("LOWER").disabled or (cur == 1)
if self._btn("HIGHER"):
self._btn("HIGHER").disabled = self._btn("HIGHER").disabled or (cur == 13)
def _btn(self, label):
for c in self.children:
if isinstance(c, discord.ui.Button) and c.label == label:
return c
return None
def _reset_round(self):
self.in_round = False
self.current_card = None
self.mult = 1.0
self.steps = 0
self.last_note = None
self._sync_buttons()
async def _start_round(self, itx: discord.Interaction):
# debit bet up front
cash, _ = db.get_wallet(self.user_id)
if self.bet > cash:
return await itx.response.send_message("Not enough cash for that bet.", ephemeral=True)
db.add_cash(self.user_id, -self.bet)
self.in_round = True
self.mult = 1.0
self.steps = 0
self.last_note = None
self.current_card = draw_card()
self._sync_buttons()
await itx.response.edit_message(embed=self.render(), view=self)
async def _resolve_step(self, itx: discord.Interaction, direction: str):
if self._busy: return
self._busy = True
from_rank, from_suit = self.current_card
cur_val = RANK_TO_VAL[from_rank]
# draw until not equal (ties push)
while True:
nrank, nsuit = draw_card()
if nrank != from_rank:
break
# gentle note, no state change
self.last_note = "Tie (same rank) — try again."
await itx.response.edit_message(embed=self.render(), view=self)
self._busy = False
return # require another click to proceed
win = (RANK_TO_VAL[nrank] > cur_val) if direction == "up" else (RANK_TO_VAL[nrank] < cur_val)
if win:
# multiply and advance
self.steps += 1
self.mult *= step_multiplier(cur_val, "up" if direction == "up" else "down")
self.mult = min(self.mult, HILO_MAX_MULT)
self.current_card = (nrank, nsuit)
self.last_note = "✅ Correct! Cash out anytime."
self._sync_buttons()
try:
await itx.response.edit_message(embed=self.render(), view=self)
finally:
self._busy = False
return
else:
# lose round: no return, bet already debited
returned = 0
db.record_hilo(self.user_id, bet=self.bet, return_amount=returned, won=False)
# Save a visible summary BEFORE resetting state
self.last_summary = {
"from": (from_rank, from_suit),
"to": (nrank, nsuit),
"guess": "HIGHER" if direction == "up" else "LOWER",
"returned": returned,
"net": -self.bet,
"steps": self.steps,
"mult": self.mult,
}
self._reset_round()
await itx.response.edit_message(
embed=self.render(), view=self
)
self._busy = False
return
async def _cashout(self, itx: discord.Interaction):
if not self.in_round:
return await itx.response.send_message("No active round.", ephemeral=True)
returned = int(self.bet * self.mult)
if returned > 0:
db.add_cash(self.user_id, returned)
db.record_hilo(self.user_id, bet=self.bet, return_amount=returned, won=(returned > self.bet))
# Save summary for post-round panel
self.last_summary = {
"from": self.current_card,
"to": None,
"guess": "CASHOUT",
"returned": returned,
"net": returned - self.bet,
"steps": self.steps,
"mult": self.mult,
}
self._reset_round()
await itx.response.edit_message(embed=self.render(), view=self)
# ----- Row 0 -----
@discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0)
async def set_bet(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("This isnt your Hi-Lo panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("You cant change bet during a round.", ephemeral=True)
await itx.response.send_modal(SetBetModal(self))
@discord.ui.button(label="×2", style=discord.ButtonStyle.secondary, row=0)
async def x2(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("You cant change bet during a round.", ephemeral=True)
self.bet = max(HILO_MIN_BET, self.bet * 2)
await itx.response.edit_message(embed=self.render(), view=self)
@discord.ui.button(label="½", style=discord.ButtonStyle.secondary, row=0)
async def half(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("You cant change bet during a round.", ephemeral=True)
self.bet = max(HILO_MIN_BET, self.bet // 2)
await itx.response.edit_message(embed=self.render(), view=self)
@discord.ui.button(label="Start", style=discord.ButtonStyle.success, emoji="🎴", row=0)
async def start(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("Already in a round.", ephemeral=True)
await self._start_round(itx)
# ----- Row 1 -----
@discord.ui.button(label="LOWER", style=discord.ButtonStyle.danger, emoji="⬇️", row=1)
async def lower(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if not self.in_round or not self.current_card: return
await self._resolve_step(itx, "down")
@discord.ui.button(label="Cash Out", style=discord.ButtonStyle.secondary, emoji="💵", row=1)
async def cash(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if not self.in_round: return
await self._cashout(itx)
@discord.ui.button(label="HIGHER", style=discord.ButtonStyle.primary, emoji="⬆️", row=1)
async def higher(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if not self.in_round or not self.current_card: return
await self._resolve_step(itx, "up")
class HiloCog(commands.Cog):
def __init__(self, bot): self.bot = bot
@commands.command(name="hilo", aliases=["hi"])
async def hilo(self, ctx: commands.Context, bet: int = None):
"""Open the Hi-Lo panel."""
uid = ctx.author.id
view = HiloView(uid, initial_bet=bet or 50, timeout=180)
msg = await ctx.send(embed=view.render(), view=view)
view.message = msg
async def setup(bot):
await bot.add_cog(HiloCog(bot))

450
src/cogs/mines.py Normal file
View File

@@ -0,0 +1,450 @@
# src/cogs/mines.py
import random
import discord
from discord.ext import commands
from typing import Optional, Set, Tuple
from .. import db
# --- Config (override via constants.py if present) ---
try:
from ..utils.constants import (
MINES_MIN_BET, MINES_EDGE_PER_STEP, MINES_MAX_MULT, MINES_CHOICES
)
except Exception:
MINES_MIN_BET = 10
MINES_EDGE_PER_STEP = 0.015 # 1.5% house edge per safe pick
MINES_MAX_MULT = 1000.0
MINES_CHOICES = [1, 3, 5, 7] # selectable mine counts
GRID_N = 5
TOTAL_CELLS = GRID_N * GRID_N
# emojis
EMO_UNREVEALED = ""
EMO_SAFE = "🟩"
EMO_MINE = "💣"
EMO_HIT = "💥" # bomb that was clicked
# labels
COL_LABELS = ["🇦","🇧","🇨","🇩","🇪"]
ROW_LABELS = ["1","2","3","4","5"]
def money(n: int) -> str:
return f"${n:,}"
def coord_to_idx(cell: Tuple[int,int]) -> int:
r, c = cell
return r * GRID_N + c
def step_multiplier(total_rem: int, safe_rem: int) -> float:
"""
Fair step MULT = 1 / P(safe) = total_rem / safe_rem.
Apply per-step house edge, clamp to >= 1.0 and <= cap.
"""
if safe_rem <= 0 or total_rem <= 0:
return 1.0
fair = total_rem / safe_rem
mult = fair * (1.0 - MINES_EDGE_PER_STEP)
return max(1.0, min(mult, MINES_MAX_MULT))
class SetBetModal(discord.ui.Modal, title="Set Mines Bet"):
def __init__(self, view: "MinesView"):
super().__init__()
self.view = view
self.amount = discord.ui.TextInput(
label=f"Amount (min {MINES_MIN_BET})",
placeholder=str(self.view.bet),
required=True, min_length=1, max_length=10,
)
self.add_item(self.amount)
async def on_submit(self, itx: discord.Interaction):
if itx.user.id != self.view.user_id:
return await itx.response.send_message("This isnt your Mines panel.", ephemeral=True)
if self.view.in_round:
return await itx.response.send_message("You cant change bet during a round.", ephemeral=True)
raw = str(self.amount.value)
try:
amt = int("".join(ch for ch in raw if ch.isdigit()))
except Exception:
return await itx.response.send_message("Enter a valid number.", ephemeral=True)
self.view.bet = max(MINES_MIN_BET, amt)
await itx.response.defer(ephemeral=True)
if self.view.message:
await self.view.message.edit(embed=self.view.render(), view=self.view)
class PickCellModal(discord.ui.Modal, title="Pick a Cell (A1E5)"):
def __init__(self, view: "MinesView"):
super().__init__()
self.view = view
self.cell = discord.ui.TextInput(
label="Cell (e.g., A1, C3, E5)",
placeholder="A1",
required=True,
min_length=2, max_length=3,
)
self.add_item(self.cell)
async def on_submit(self, itx: discord.Interaction):
if itx.user.id != self.view.user_id:
return await itx.response.send_message("This isnt your Mines panel.", ephemeral=True)
if not self.view.in_round:
return await itx.response.send_message("No active round.", ephemeral=True)
raw = self.cell.value.strip().upper().replace(" ", "")
if len(raw) < 2:
return await itx.response.send_message("Invalid cell.", ephemeral=True)
col_ch = raw[0]
row_str = raw[1:]
if col_ch not in "ABCDE":
return await itx.response.send_message("Column must be AE.", ephemeral=True)
if not row_str.isdigit():
return await itx.response.send_message("Row must be 15.", ephemeral=True)
r = int(row_str) - 1
c = "ABCDE".index(col_ch)
if r < 0 or r >= GRID_N or c < 0 or c >= GRID_N:
return await itx.response.send_message("Out of range.", ephemeral=True)
await itx.response.defer(ephemeral=True)
await self.view.reveal_cell((r, c))
class MinesView(discord.ui.View):
"""
Row 0: Set Bet · ×2 · ½ · Mines:[N] · Start
Row 1: Pick Cell · Pick Random · Cash Out
"""
def __init__(self, user_id: int, initial_bet: int = 50, *, timeout: int = 180):
super().__init__(timeout=timeout)
self.user_id = user_id
self.bet = max(MINES_MIN_BET, initial_bet)
self.mines_options = MINES_CHOICES[:]
self.mines_index = 0
self.mines_count = self.mines_options[self.mines_index]
# round state
self.in_round = False
self.mines: Set[int] = set()
self.revealed: Set[int] = set()
self.mult = 1.0
self.total_rem = TOTAL_CELLS
self.safe_rem = TOTAL_CELLS - self.mines_count
self.last_summary: Optional[dict] = None # show after round ends
self.message: Optional[discord.Message] = None
self._busy = False
self._sync_buttons()
# ----- lifecycle -----
def disable_all(self):
for c in self.children:
c.disabled = True
async def on_timeout(self):
self.disable_all()
try:
if self.message:
await self.message.edit(view=self)
except:
pass
# ----- rendering -----
def _grid_text(
self,
show_mines: bool = False,
with_labels: bool = True,
*,
mines: Optional[Set[int]] = None,
revealed: Optional[Set[int]] = None,
hit_idx: Optional[int] = None
) -> str:
"""Return a labeled 5×5 grid as lines of emojis. Can render from snapshots."""
mines = self.mines if mines is None else mines
revealed = self.revealed if revealed is None else revealed
lines = []
if with_labels:
lines.append("" + " ".join(COL_LABELS))
for r in range(GRID_N):
row_emojis = []
for c in range(GRID_N):
idx = coord_to_idx((r, c))
if idx in revealed:
# if we want the clicked bomb to show as 💥 even if counted as revealed, prefer 💥
if show_mines and hit_idx is not None and idx == hit_idx:
row_emojis.append(EMO_HIT)
else:
row_emojis.append(EMO_SAFE)
else:
if show_mines and idx in mines:
row_emojis.append(EMO_HIT if (hit_idx is not None and idx == hit_idx) else EMO_MINE)
else:
row_emojis.append(EMO_UNREVEALED)
line = " ".join(row_emojis)
if with_labels:
line = f"{ROW_LABELS[r]} {line}"
lines.append(line)
return "\n".join(lines)
def render(self) -> discord.Embed:
# color based on last result or active
if self.in_round:
color = discord.Color.green()
elif self.last_summary is not None:
net = self.last_summary.get("net", 0)
color = discord.Color.green() if net > 0 else (discord.Color.red() if net < 0 else discord.Color.orange())
else:
color = discord.Color.blurple()
e = discord.Embed(title="💣 Mines", color=color)
cash, _ = db.get_wallet(self.user_id)
if not self.in_round:
desc = [
f"**Bet:** {money(self.bet)} • **Mines:** {self.mines_count}",
f"**Balance:** {money(cash)}",
"",
"Press **Start** to place mines and begin. Then **Pick Cell** (A1E5) or **Pick Random**.",
"Each safe pick increases your multiplier; **Cash Out** anytime.",
]
if self.last_summary is not None:
sym = "💰" if self.last_summary["net"] > 0 else ("" if self.last_summary["net"] < 0 else "↔️")
returned = self.last_summary["returned"]
net = self.last_summary["net"]
picks = self.last_summary["picks"]
mult = self.last_summary["mult"]
how = self.last_summary["how"]
if how == "MINE":
where = self.last_summary.get("cell_str", "?")
desc += [
"",
f"**Last Result:** {sym} Hit a mine at **{where}**.",
f"Safe picks: **{picks}**, Mult: **×{mult:.2f}**, Returned: **{money(returned)}**, Net: **{'+' if net>0 else ''}{money(net)}**",
]
else:
desc += [
"",
f"**Last Result:** {sym} Cashed out.",
f"Safe picks: **{picks}**, Mult: **×{mult:.2f}**, Returned: **{money(returned)}**, Net: **{'+' if net>0 else ''}{money(net)}**",
]
e.description = "\n".join(desc)
# NEW: show snapshot board if present
if self.last_summary is not None and self.last_summary.get("board"):
e.add_field(name="Board", value=self.last_summary["board"], inline=False)
return e
# in-round panel
grid = self._grid_text(show_mines=False, with_labels=True)
e.description = (
f"**Bet:** {money(self.bet)} • **Mines:** {self.mines_count}\n"
f"**Picks:** {len(self.revealed)} • **Multiplier:** ×{self.mult:.2f}"
f"**Cashout:** {money(int(self.bet * self.mult))}\n\n{grid}"
)
e.set_footer(text="Use Pick Cell (A1E5) or Pick Random. Cash Out anytime.")
return e
# ----- helpers -----
def _sync_buttons(self):
active = self.in_round
# Row 0 controls: disabled in-round (except Start which is disabled once in-round)
for lbl in ("Set Bet", "×2", "½", None, "Start"):
b = self._btn(lbl) if lbl else None
if b:
b.disabled = active if lbl != "Start" else active
# Mines button enabled only when not in round
mines_btn = self._btn(f"Mines: {self.mines_count}")
if mines_btn:
mines_btn.disabled = active
# Row 1 controls: enabled only in-round
for lbl in ("Pick Cell", "Pick Random", "Cash Out"):
b = self._btn(lbl)
if b: b.disabled = not active
def _btn(self, label: Optional[str]):
for c in self.children:
if isinstance(c, discord.ui.Button) and c.label == label:
return c
return None
def _reset_round(self):
self.in_round = False
self.mines.clear()
self.revealed.clear()
self.mult = 1.0
self.total_rem = TOTAL_CELLS
self.safe_rem = TOTAL_CELLS - self.mines_count
self._sync_buttons()
async def start_round(self, itx: discord.Interaction):
cash, _ = db.get_wallet(self.user_id)
if self.bet > cash:
return await itx.response.send_message("Not enough cash for that bet.", ephemeral=True)
# debit and place mines
db.add_cash(self.user_id, -self.bet)
self.in_round = True
self.mult = 1.0
self.revealed = set()
all_idx = list(range(TOTAL_CELLS))
self.mines = set(random.sample(all_idx, self.mines_count))
self.total_rem = TOTAL_CELLS
self.safe_rem = TOTAL_CELLS - self.mines_count
self._sync_buttons()
await itx.response.edit_message(embed=self.render(), view=self)
def _cell_str(self, rc: Tuple[int,int]) -> str:
col = "ABCDE"[rc[1]]
row = rc[0] + 1
return f"{col}{row}"
async def reveal_cell(self, rc: Tuple[int,int]):
if self._busy or not self.in_round:
return
self._busy = True
idx = coord_to_idx(rc)
# already revealed?
if idx in self.revealed:
self._busy = False
return
if idx in self.mines:
# build snapshot BEFORE resetting: show all mines and mark the hit
snapshot_board = self._grid_text(
show_mines=True, with_labels=True,
mines=set(self.mines), revealed=set(self.revealed), hit_idx=idx
)
returned = 0
db.record_mines(self.user_id, bet=self.bet, return_amount=returned, won=False)
self.last_summary = {
"how": "MINE",
"cell_str": self._cell_str(rc),
"picks": len(self.revealed),
"mult": self.mult,
"returned": returned,
"net": -self.bet,
"board": snapshot_board,
}
self._reset_round()
if self.message:
await self.message.edit(embed=self.render(), view=self)
self._busy = False
return
# success — update multiplier and state
step_mult = step_multiplier(self.total_rem, self.safe_rem)
self.mult = min(self.mult * step_mult, MINES_MAX_MULT)
self.revealed.add(idx)
self.total_rem -= 1
self.safe_rem -= 1
if self.message:
await self.message.edit(embed=self.render(), view=self)
self._busy = False
async def pick_random(self, itx: discord.Interaction):
# choose any unrevealed cell uniformly
unrevealed = [i for i in range(TOTAL_CELLS) if i not in self.revealed]
if not unrevealed:
return await itx.response.send_message("All cells are revealed!", ephemeral=True)
choice = random.choice(unrevealed)
r, c = divmod(choice, GRID_N)
await itx.response.defer(ephemeral=True)
await self.reveal_cell((r, c))
async def cash_out(self, itx: discord.Interaction):
if not self.in_round:
return await itx.response.send_message("No active round.", ephemeral=True)
returned = int(self.bet * self.mult)
if returned > 0:
db.add_cash(self.user_id, returned)
db.record_mines(self.user_id, bet=self.bet, return_amount=returned, won=(returned > self.bet))
# snapshot of the revealed greens at cashout
snapshot_board = self._grid_text(
show_mines=False, with_labels=True,
mines=set(self.mines), revealed=set(self.revealed)
)
self.last_summary = {
"how": "CASH",
"picks": len(self.revealed),
"mult": self.mult,
"returned": returned,
"net": returned - self.bet,
"board": snapshot_board,
}
self._reset_round()
await itx.response.edit_message(embed=self.render(), view=self)
# ----- Row 0 controls -----
@discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0)
async def set_bet(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("You cant change bet during a round.", ephemeral=True)
await itx.response.send_modal(SetBetModal(self))
@discord.ui.button(label="×2", style=discord.ButtonStyle.secondary, row=0)
async def x2(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("You cant change bet during a round.", ephemeral=True)
self.bet = max(MINES_MIN_BET, self.bet * 2)
await itx.response.edit_message(embed=self.render(), view=self)
@discord.ui.button(label="½", style=discord.ButtonStyle.secondary, row=0)
async def half(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("You cant change bet during a round.", ephemeral=True)
self.bet = max(MINES_MIN_BET, self.bet // 2)
await itx.response.edit_message(embed=self.render(), view=self)
@discord.ui.button(label="Start", style=discord.ButtonStyle.success, emoji="🚩", row=0)
async def start(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("Already in a round.", ephemeral=True)
await self.start_round(itx)
# "Mines: N" (cycles through your MINES_CHOICES)
@discord.ui.button(label="Mines: 1", style=discord.ButtonStyle.secondary, row=0)
async def mines_btn(self, itx: discord.Interaction, button: discord.ui.Button):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if self.in_round: return await itx.response.send_message("Change mines before starting.", ephemeral=True)
self.mines_index = (self.mines_index + 1) % len(self.mines_options)
self.mines_count = self.mines_options[self.mines_index]
button.label = f"Mines: {self.mines_count}"
await itx.response.edit_message(embed=self.render(), view=self)
# ----- Row 1 controls -----
@discord.ui.button(label="Pick Cell", style=discord.ButtonStyle.primary, emoji="🎯", row=1)
async def pick_cell(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if not self.in_round: return await itx.response.send_message("No active round.", ephemeral=True)
await itx.response.send_modal(PickCellModal(self))
@discord.ui.button(label="Pick Random", style=discord.ButtonStyle.secondary, emoji="🎲", row=1)
async def pick_rand(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if not self.in_round: return await itx.response.send_message("No active round.", ephemeral=True)
await self.pick_random(itx)
@discord.ui.button(label="Cash Out", style=discord.ButtonStyle.danger, emoji="💵", row=1)
async def cash(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
if not self.in_round: return await itx.response.send_message("No active round.", ephemeral=True)
await self.cash_out(itx)
class MinesCog(commands.Cog):
def __init__(self, bot): self.bot = bot
@commands.command(name="mines")
async def mines(self, ctx: commands.Context, bet: int = None):
uid = ctx.author.id
view = MinesView(uid, initial_bet=bet or MINES_MIN_BET, timeout=180)
msg = await ctx.send(embed=view.render(), view=view)
view.message = msg
async def setup(bot):
await bot.add_cog(MinesCog(bot))

469
src/cogs/packs.py Normal file
View File

@@ -0,0 +1,469 @@
# src/cogs/packs.py
import random
import discord
from discord.ext import commands
from typing import Optional, Dict, List, Tuple
from .. import db
# ---- Tunables (override via constants.py if you want) ----
try:
from ..utils.constants import PACK_MIN_BET, PACK_SIZE, PACK_RARITY_WEIGHTS
except Exception:
PACK_MIN_BET = 50
PACK_SIZE = 5
# weights per rarity (sum doesn't need to be 1; we normalize)
PACK_RARITY_WEIGHTS = {
"Common": 600,
"Uncommon": 250,
"Rare": 100,
"Epic": 40,
"Legendary": 9,
"Mythic": 1,
}
# -----------------------------------------------------------------------------
# Catalog: id, emoji, display name, rarity, multiplier (× bet)
# Kept your original 25 items & IDs; expanded to 100 total.
# -----------------------------------------------------------------------------
CATALOG: List[Dict] = [
# ---------------------- Common (40) ----------------------
{"id":"cherry","emoji":"🍒","name":"Cherries","rarity":"Common","mult":0.10},
{"id":"coin","emoji":"🪙","name":"Coin","rarity":"Common","mult":0.12},
{"id":"mug","emoji":"","name":"Mug","rarity":"Common","mult":0.15},
{"id":"leaf","emoji":"🌿","name":"Leaf","rarity":"Common","mult":0.18},
{"id":"sock","emoji":"🧦","name":"Sock","rarity":"Common","mult":0.20},
{"id":"teddy","emoji":"🧸","name":"Teddy","rarity":"Common","mult":0.22},
{"id":"ice","emoji":"🧊","name":"Ice","rarity":"Common","mult":0.24},
{"id":"clover","emoji":"🍀","name":"Clover","rarity":"Common","mult":0.25},
{"id":"apple","emoji":"🍎","name":"Apple","rarity":"Common","mult":0.12},
{"id":"banana","emoji":"🍌","name":"Banana","rarity":"Common","mult":0.14},
{"id":"pear","emoji":"🍐","name":"Pear","rarity":"Common","mult":0.13},
{"id":"peach","emoji":"🍑","name":"Peach","rarity":"Common","mult":0.16},
{"id":"strawberry","emoji":"🍓","name":"Strawberry","rarity":"Common","mult":0.18},
{"id":"grapes","emoji":"🍇","name":"Grapes","rarity":"Common","mult":0.20},
{"id":"carrot","emoji":"🥕","name":"Carrot","rarity":"Common","mult":0.12},
{"id":"corn","emoji":"🌽","name":"Corn","rarity":"Common","mult":0.15},
{"id":"bread","emoji":"🍞","name":"Bread","rarity":"Common","mult":0.14},
{"id":"cheese","emoji":"🧀","name":"Cheese","rarity":"Common","mult":0.18},
{"id":"egg","emoji":"🥚","name":"Egg","rarity":"Common","mult":0.16},
{"id":"milk","emoji":"🥛","name":"Milk","rarity":"Common","mult":0.12},
{"id":"mushroom","emoji":"🍄","name":"Mushroom","rarity":"Common","mult":0.20},
{"id":"seedling","emoji":"🌱","name":"Seedling","rarity":"Common","mult":0.16},
{"id":"flower","emoji":"🌼","name":"Flower","rarity":"Common","mult":0.16},
{"id":"maple","emoji":"🍁","name":"Maple Leaf","rarity":"Common","mult":0.18},
{"id":"sun","emoji":"☀️","name":"Sun","rarity":"Common","mult":0.24},
{"id":"moon","emoji":"🌙","name":"Moon","rarity":"Common","mult":0.22},
{"id":"cloud","emoji":"☁️","name":"Cloud","rarity":"Common","mult":0.18},
{"id":"snowflake","emoji":"❄️","name":"Snowflake","rarity":"Common","mult":0.20},
{"id":"umbrella","emoji":"☂️","name":"Umbrella","rarity":"Common","mult":0.18},
{"id":"balloon","emoji":"🎈","name":"Balloon","rarity":"Common","mult":0.22},
{"id":"pencil","emoji":"✏️","name":"Pencil","rarity":"Common","mult":0.14},
{"id":"book","emoji":"📘","name":"Book","rarity":"Common","mult":0.16},
{"id":"paperclip","emoji":"🖇️","name":"Paperclip","rarity":"Common","mult":0.14},
{"id":"scissors","emoji":"✂️","name":"Scissors","rarity":"Common","mult":0.16},
{"id":"bulb","emoji":"💡","name":"Light Bulb","rarity":"Common","mult":0.24},
{"id":"battery","emoji":"🔋","name":"Battery","rarity":"Common","mult":0.22},
{"id":"wrench","emoji":"🔧","name":"Wrench","rarity":"Common","mult":0.20},
{"id":"hammer","emoji":"🔨","name":"Hammer","rarity":"Common","mult":0.20},
{"id":"camera","emoji":"📷","name":"Camera","rarity":"Common","mult":0.24},
{"id":"gamepad","emoji":"🎮","name":"Gamepad","rarity":"Common","mult":0.24},
# -------------------- Uncommon (25) ---------------------
{"id":"donut","emoji":"🍩","name":"Donut","rarity":"Uncommon","mult":0.35},
{"id":"pizza","emoji":"🍕","name":"Pizza","rarity":"Uncommon","mult":0.40},
{"id":"soccer","emoji":"","name":"Soccer Ball","rarity":"Uncommon","mult":0.45},
{"id":"headset","emoji":"🎧","name":"Headset","rarity":"Uncommon","mult":0.50},
{"id":"magnet","emoji":"🧲","name":"Magnet","rarity":"Uncommon","mult":0.55},
{"id":"cat","emoji":"🐱","name":"Cat","rarity":"Uncommon","mult":0.60},
{"id":"basketball","emoji":"🏀","name":"Basketball","rarity":"Uncommon","mult":0.45},
{"id":"baseball","emoji":"","name":"Baseball","rarity":"Uncommon","mult":0.42},
{"id":"guitar","emoji":"🎸","name":"Guitar","rarity":"Uncommon","mult":0.55},
{"id":"violin","emoji":"🎻","name":"Violin","rarity":"Uncommon","mult":0.58},
{"id":"joystick","emoji":"🕹️","name":"Joystick","rarity":"Uncommon","mult":0.50},
{"id":"keyboard","emoji":"⌨️","name":"Keyboard","rarity":"Uncommon","mult":0.48},
{"id":"laptop","emoji":"💻","name":"Laptop","rarity":"Uncommon","mult":0.60},
{"id":"robot","emoji":"🤖","name":"Robot","rarity":"Uncommon","mult":0.62},
{"id":"dog","emoji":"🐶","name":"Dog","rarity":"Uncommon","mult":0.55},
{"id":"fox","emoji":"🦊","name":"Fox","rarity":"Uncommon","mult":0.58},
{"id":"penguin","emoji":"🐧","name":"Penguin","rarity":"Uncommon","mult":0.60},
{"id":"koala","emoji":"🐨","name":"Koala","rarity":"Uncommon","mult":0.60},
{"id":"panda","emoji":"🐼","name":"Panda","rarity":"Uncommon","mult":0.60},
{"id":"owl","emoji":"🦉","name":"Owl","rarity":"Uncommon","mult":0.65},
{"id":"butterfly","emoji":"🦋","name":"Butterfly","rarity":"Uncommon","mult":0.65},
{"id":"car","emoji":"🚗","name":"Car","rarity":"Uncommon","mult":0.55},
{"id":"train","emoji":"🚆","name":"Train","rarity":"Uncommon","mult":0.55},
{"id":"sailboat","emoji":"","name":"Sailboat","rarity":"Uncommon","mult":0.58},
{"id":"airplane","emoji":"✈️","name":"Airplane","rarity":"Uncommon","mult":0.62},
# ---------------------- Rare (18) -----------------------
{"id":"melon","emoji":"🍉","name":"Watermelon","rarity":"Rare","mult":0.85},
{"id":"tiger","emoji":"🐯","name":"Tiger","rarity":"Rare","mult":1.00},
{"id":"ufo","emoji":"🛸","name":"UFO","rarity":"Rare","mult":1.10},
{"id":"unicorn","emoji":"🦄","name":"Unicorn","rarity":"Rare","mult":1.20},
{"id":"elephant","emoji":"🐘","name":"Elephant","rarity":"Rare","mult":0.95},
{"id":"lion","emoji":"🦁","name":"Lion","rarity":"Rare","mult":1.05},
{"id":"wolf","emoji":"🐺","name":"Wolf","rarity":"Rare","mult":1.05},
{"id":"dolphin","emoji":"🐬","name":"Dolphin","rarity":"Rare","mult":0.90},
{"id":"whale","emoji":"🐳","name":"Whale","rarity":"Rare","mult":0.90},
{"id":"alien","emoji":"👽","name":"Alien","rarity":"Rare","mult":1.10},
{"id":"ghost","emoji":"👻","name":"Ghost","rarity":"Rare","mult":1.00},
{"id":"crystalball","emoji":"🔮","name":"Crystal Ball","rarity":"Rare","mult":1.10},
{"id":"satellite","emoji":"🛰️","name":"Satellite","rarity":"Rare","mult":1.15},
{"id":"comet","emoji":"☄️","name":"Comet","rarity":"Rare","mult":1.20},
{"id":"ninja","emoji":"🥷","name":"Ninja","rarity":"Rare","mult":1.10},
{"id":"mountain","emoji":"⛰️","name":"Mountain","rarity":"Rare","mult":0.95},
{"id":"volcano","emoji":"🌋","name":"Volcano","rarity":"Rare","mult":1.00},
{"id":"ring_rare","emoji":"💍","name":"Ring","rarity":"Rare","mult":1.25},
# ---------------------- Epic (10) -----------------------
{"id":"dragon","emoji":"🐉","name":"Dragon","rarity":"Epic","mult":2.0},
{"id":"lab","emoji":"🧪","name":"Lab Flask","rarity":"Epic","mult":2.5},
{"id":"rocket","emoji":"🚀","name":"Rocket","rarity":"Epic","mult":3.0},
{"id":"wizard","emoji":"🧙","name":"Wizard","rarity":"Epic","mult":2.2},
{"id":"wand","emoji":"🪄","name":"Magic Wand","rarity":"Epic","mult":2.6},
{"id":"dragonface","emoji":"🐲","name":"Dragon Face","rarity":"Epic","mult":2.8},
{"id":"castle","emoji":"🏰","name":"Castle","rarity":"Epic","mult":2.4},
{"id":"shield","emoji":"🛡️","name":"Shield","rarity":"Epic","mult":2.3},
{"id":"swords","emoji":"⚔️","name":"Crossed Swords","rarity":"Epic","mult":2.7},
{"id":"trophy_epic","emoji":"🏆","name":"Trophy","rarity":"Epic","mult":3.2},
# ------------------- Legendary (5) ----------------------
{"id":"crown","emoji":"👑","name":"Crown","rarity":"Legendary","mult":6.0},
{"id":"eagle","emoji":"🦅","name":"Eagle","rarity":"Legendary","mult":7.5},
{"id":"medal","emoji":"🥇","name":"Gold Medal","rarity":"Legendary","mult":9.0},
{"id":"moneybag","emoji":"💰","name":"Money Bag","rarity":"Legendary","mult":8.5},
{"id":"ring_legend","emoji":"💍","name":"Royal Ring","rarity":"Legendary","mult":10.0},
# --------------------- Mythic (2) -----------------------
{"id":"dino","emoji":"🦖","name":"Dino","rarity":"Mythic","mult":25.0},
{"id":"diamond","emoji":"💎","name":"Diamond","rarity":"Mythic","mult":50.0},
]
# Build rarity index
RARITY_TO_ITEMS: Dict[str, List[Dict]] = {}
for it in CATALOG:
RARITY_TO_ITEMS.setdefault(it["rarity"], []).append(it)
def _norm_weights(weights: Dict[str, float]) -> Dict[str, float]:
s = float(sum(weights.values()))
return {k: (v / s if s > 0 else 0.0) for k, v in weights.items()}
NORM_RARITY = _norm_weights(PACK_RARITY_WEIGHTS)
def _weighted_choice(items: List[Tuple[float, Dict]]) -> Dict:
"""items: list of (weight, item) where weight >= 0"""
total = sum(w for w, _ in items)
r = random.random() * total
upto = 0.0
for w, it in items:
upto += w
if r <= upto:
return it
return items[-1][1]
def pick_one() -> Dict:
# pick rarity
rar_roll = _weighted_choice([(p, {"rarity": r}) for r, p in NORM_RARITY.items()])["rarity"]
pool = RARITY_TO_ITEMS.get(rar_roll, CATALOG)
# equal within rarity
return random.choice(pool)
def money(n: int) -> str:
return f"${n:,}"
# ---------- Pagination helpers ----------
RARITY_RANK = {"Mythic": 0, "Legendary": 1, "Epic": 2, "Rare": 3, "Uncommon": 4, "Common": 5}
SORTED_CATALOG = sorted(
CATALOG,
key=lambda it: (RARITY_RANK.get(it["rarity"], 99), -float(it["mult"]), it["name"])
)
# Attach rank (1 = rarest)
for i, it in enumerate(SORTED_CATALOG, 1):
it["_rank"] = i
PAGE_SIZE = 20 # 100 items -> 5 pages
def _collection_page_lines(user_id: int, page: int) -> Tuple[str, int, int, int]:
"""
Returns (text, start_idx, end_idx, total_pages) for the given page (0-based).
"""
coll = db.get_packs_collection(user_id)
total = len(SORTED_CATALOG)
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page = max(0, min(page, pages - 1))
start = page * PAGE_SIZE
end = min(total, start + PAGE_SIZE)
lines: List[str] = []
for it in SORTED_CATALOG[start:end]:
owned = coll.get(it["id"], 0) > 0
idx = it["_rank"]
if owned:
lines.append(f"**#{idx:02d}** {it['emoji']} **{it['name']}** · *{it['rarity']}*")
else:
lines.append(f"**#{idx:02d}** ?")
text = "\n".join(lines)
return text, start, end, pages
def _collection_embed(user_id: int, page: int) -> discord.Embed:
coll = db.get_packs_collection(user_id)
have = sum(1 for it in SORTED_CATALOG if coll.get(it["id"], 0) > 0)
total = len(SORTED_CATALOG)
text, start, end, pages = _collection_page_lines(user_id, page)
e = discord.Embed(
title="🗂️ Packs Collection",
description=f"**Progress:** {have}/{total} collected\n"
f"Rarest is **#01**, Common end is **#{total:02d}**.\n\n{text}",
color=discord.Color.dark_gold()
)
e.set_footer(text=f"Page {page+1}/{pages} • Showing #{start+1:02d}#{end:02d}")
return e
# ---------- UI: Set Bet modal ----------
class SetBetModal(discord.ui.Modal, title="Set Packs Bet"):
def __init__(self, view: "PacksView"):
super().__init__()
self.view = view
self.amount = discord.ui.TextInput(
label=f"Amount (min {PACK_MIN_BET})",
placeholder=str(self.view.bet),
required=True, min_length=1, max_length=10,
)
self.add_item(self.amount)
async def on_submit(self, itx: discord.Interaction):
if itx.user.id != self.view.user_id:
return await itx.response.send_message("This isnt your Packs panel.", ephemeral=True)
if self.view.busy:
return await itx.response.send_message("Please wait a moment.", ephemeral=True)
raw = str(self.amount.value)
try:
amt = int("".join(ch for ch in raw if ch.isdigit()))
except Exception:
return await itx.response.send_message("Enter a valid number.", ephemeral=True)
self.view.bet = max(PACK_MIN_BET, amt)
await itx.response.defer(ephemeral=True)
if self.view.message:
await self.view.message.edit(embed=self.view.render(), view=self.view)
# ---------- UI: Collection View ----------
class PacksCollectionView(discord.ui.View):
def __init__(self, user_id: int, *, start_page: int = 0, timeout: int = 180):
super().__init__(timeout=timeout)
self.user_id = user_id
self.page = start_page
self.pages = max(1, (len(SORTED_CATALOG) + PAGE_SIZE - 1) // PAGE_SIZE)
self.message: Optional[discord.Message] = None
async def on_timeout(self):
for c in self.children:
c.disabled = True
try:
if self.message:
await self.message.edit(view=self)
except:
pass
async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user.id != self.user_id:
await interaction.response.send_message("This collection belongs to someone else.", ephemeral=True)
return False
return True
def _sync(self):
# enable/disable nav buttons based on current page
for c in self.children:
if isinstance(c, discord.ui.Button):
if c.custom_id == "first": c.disabled = (self.page <= 0)
if c.custom_id == "prev": c.disabled = (self.page <= 0)
if c.custom_id == "next": c.disabled = (self.page >= self.pages - 1)
if c.custom_id == "last": c.disabled = (self.page >= self.pages - 1)
def embed(self) -> discord.Embed:
self._sync()
return _collection_embed(self.user_id, self.page)
@discord.ui.button(label="⏮️", style=discord.ButtonStyle.secondary, custom_id="first")
async def first(self, itx: discord.Interaction, _):
self.page = 0
await itx.response.edit_message(embed=self.embed(), view=self)
@discord.ui.button(label="◀️", style=discord.ButtonStyle.secondary, custom_id="prev")
async def prev(self, itx: discord.Interaction, _):
if self.page > 0:
self.page -= 1
await itx.response.edit_message(embed=self.embed(), view=self)
@discord.ui.button(label="▶️", style=discord.ButtonStyle.secondary, custom_id="next")
async def next(self, itx: discord.Interaction, _):
if self.page < self.pages - 1:
self.page += 1
await itx.response.edit_message(embed=self.embed(), view=self)
@discord.ui.button(label="⏭️", style=discord.ButtonStyle.secondary, custom_id="last")
async def last(self, itx: discord.Interaction, _):
self.page = self.pages - 1
await itx.response.edit_message(embed=self.embed(), view=self)
@discord.ui.button(label="Close", style=discord.ButtonStyle.danger)
async def close(self, itx: discord.Interaction, _):
try:
await itx.message.delete()
except:
try:
for c in self.children: c.disabled = True
await itx.response.edit_message(view=self)
except:
pass
# ---------- Packs game UI ----------
class PacksView(discord.ui.View):
"""
Row 0: Set Bet · ×2 · ½ · Open Pack
Row 1: Open Again · Collection
"""
def __init__(self, user_id: int, initial_bet: int = 100, *, timeout: int = 180):
super().__init__(timeout=timeout)
self.user_id = user_id
self.bet = max(PACK_MIN_BET, initial_bet)
self.last_pack: Optional[List[Dict]] = None
self.last_return: int = 0
self.last_net: int = 0
self.busy = False
self.message: Optional[discord.Message] = None
async def on_timeout(self):
for c in self.children: c.disabled = True
try:
if self.message: await self.message.edit(view=self)
except: pass
# ---- render
def render(self) -> discord.Embed:
color = discord.Color.blurple()
if self.last_pack is not None:
color = discord.Color.green() if self.last_net > 0 else (discord.Color.red() if self.last_net < 0 else discord.Color.orange())
e = discord.Embed(title="📦 Packs", color=color)
cash, _ = db.get_wallet(self.user_id)
desc = [f"**Bet:** {money(self.bet)} • **Balance:** {money(cash)}"]
if self.last_pack is None:
desc += ["Open a pack of 5 items. Each item pays its multiplier × your bet. Collect them all!"]
e.description = "\n".join(desc)
if self.last_pack is not None:
lines = []
for it in self.last_pack:
tag = "NEW" if it.get("_new") else "DUP"
lines.append(f"{it['emoji']} **{it['name']}** · *{it['rarity']}* · ×{it['mult']:.2f} `{tag}`")
e.add_field(name="Last Pack", value="\n".join(lines), inline=False)
e.add_field(
name="Result",
value=f"Return: **{money(self.last_return)}** • Net: **{'+' if self.last_net>0 else ''}{money(self.last_net)}**",
inline=False
)
return e
# ---- actions
async def _open_pack(self, itx: discord.Interaction):
if self.busy: return
self.busy = True
cash, _ = db.get_wallet(self.user_id)
if self.bet > cash:
self.busy = False
return await itx.response.send_message("Not enough cash for that bet.", ephemeral=True)
# debit
db.add_cash(self.user_id, -self.bet)
# current collection to mark NEW/DUP
coll = db.get_packs_collection(self.user_id) # dict card_id -> qty
pulled: List[Dict] = []
total_mult = 0.0
for _ in range(PACK_SIZE):
it = pick_one().copy()
it["_new"] = (coll.get(it["id"], 0) == 0)
pulled.append(it)
total_mult += it["mult"]
# persist collection increment
db.add_pack_card(self.user_id, it["id"], 1)
coll[it["id"]] = coll.get(it["id"], 0) + 1
returned = int(self.bet * total_mult)
if returned > 0:
db.add_cash(self.user_id, returned)
db.record_packs_open(self.user_id, bet=self.bet, return_amount=returned, won=(returned > self.bet))
self.last_pack = pulled
self.last_return = returned
self.last_net = returned - self.bet
await itx.response.edit_message(embed=self.render(), view=self)
self.busy = False
# ----- Buttons
@discord.ui.button(label="Set Bet", style=discord.ButtonStyle.secondary, row=0)
async def set_bet(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
await itx.response.send_modal(SetBetModal(self))
@discord.ui.button(label="×2", style=discord.ButtonStyle.secondary, row=0)
async def x2(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
self.bet = max(PACK_MIN_BET, self.bet * 2)
await itx.response.edit_message(embed=self.render(), view=self)
@discord.ui.button(label="½", style=discord.ButtonStyle.secondary, row=0)
async def half(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
self.bet = max(PACK_MIN_BET, self.bet // 2)
await itx.response.edit_message(embed=self.render(), view=self)
@discord.ui.button(label="Open Pack", style=discord.ButtonStyle.success, emoji="🎁", row=0)
async def open_pack(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
await self._open_pack(itx)
@discord.ui.button(label="Open Again", style=discord.ButtonStyle.primary, emoji="🔁", row=1)
async def open_again(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id: return await itx.response.send_message("Not your panel.", ephemeral=True)
await self._open_pack(itx)
@discord.ui.button(label="Collection", style=discord.ButtonStyle.secondary, emoji="🗂️", row=1)
async def collection(self, itx: discord.Interaction, _):
if itx.user.id != self.user_id:
return await itx.response.send_message("This isnt your Packs panel.", ephemeral=True)
view = PacksCollectionView(self.user_id, start_page=0, timeout=180)
await itx.response.send_message(embed=view.embed(), view=view, ephemeral=True)
# ---------- Cog ----------
class PacksCog(commands.Cog):
def __init__(self, bot): self.bot = bot
@commands.command(name="packs")
async def packs(self, ctx: commands.Context, bet: int = None):
uid = ctx.author.id
view = PacksView(uid, initial_bet=bet or PACK_MIN_BET, timeout=180)
msg = await ctx.send(embed=view.render(), view=view)
view.message = msg
@commands.command(name="collection", aliases=["packdex","packsdex"])
async def collection(self, ctx: commands.Context):
uid = ctx.author.id
view = PacksCollectionView(uid, start_page=0, timeout=180)
msg = await ctx.send(embed=view.embed(), view=view)
view.message = msg
async def setup(bot):
# make sure DB tables exist for packs
db.ensure_packs_tables()
await bot.add_cog(PacksCog(bot))

293
src/db.py
View File

@@ -6,11 +6,16 @@ DB_PATH = os.getenv("DB_PATH", "/app/data/blackjack.db")
def _connect():
return sqlite3.connect(DB_PATH)
# --------------------------- bootstrap / migrations ---------------------------
def init_db():
"""
Creates base tables and safely migrates new columns over time.
"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = _connect()
cur = conn.cursor()
# base
conn = _connect(); cur = conn.cursor()
# Base users table
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
@@ -20,43 +25,86 @@ def init_db():
last_daily DATE DEFAULT NULL
)
""")
# add new columns if missing
# Existing columns
cur.execute("PRAGMA table_info(users)")
cols = {row[1] for row in cur.fetchall()}
add_cols = []
# shared / economy
if "free_spins" not in cols: add_cols.append(("free_spins", "INTEGER DEFAULT 0"))
if "last_topup" not in cols: add_cols.append(("last_topup", "TEXT DEFAULT NULL"))
if "last_daily_ts" not in cols:add_cols.append(("last_daily_ts", "TEXT DEFAULT NULL"))
# blackjack
if "games_played_blackjack" not in cols: add_cols.append(("games_played_blackjack", "INTEGER DEFAULT 0"))
if "games_won_blackjack" not in cols: add_cols.append(("games_won_blackjack", "INTEGER DEFAULT 0"))
# slots
if "games_played_slots" not in cols: add_cols.append(("games_played_slots", "INTEGER DEFAULT 0"))
if "games_won_slots" not in cols: add_cols.append(("games_won_slots", "INTEGER DEFAULT 0"))
if "slots_biggest_win" not in cols: add_cols.append(("slots_biggest_win", "INTEGER DEFAULT 0"))
# roulette
if "games_played_roulette" not in cols: add_cols.append(("games_played_roulette", "INTEGER DEFAULT 0"))
if "roulette_biggest_win" not in cols: add_cols.append(("roulette_biggest_win", "INTEGER DEFAULT 0"))
if "roulette_net" not in cols: add_cols.append(("roulette_net", "INTEGER DEFAULT 0"))
# coin flip
if "games_played_coinflip" not in cols: add_cols.append(("games_played_coinflip", "INTEGER DEFAULT 0"))
if "games_won_coinflip" not in cols: add_cols.append(("games_won_coinflip", "INTEGER DEFAULT 0"))
if "coinflip_biggest_win" not in cols: add_cols.append(("coinflip_biggest_win", "INTEGER DEFAULT 0"))
if "coinflip_net" not in cols: add_cols.append(("coinflip_net", "INTEGER DEFAULT 0"))
# towers
if "games_played_towers" not in cols: add_cols.append(("games_played_towers", "INTEGER DEFAULT 0"))
if "games_won_towers" not in cols: add_cols.append(("games_won_towers", "INTEGER DEFAULT 0"))
# baccarat (NEW)
if "games_played_baccarat" not in cols: add_cols.append(("games_played_baccarat", "INTEGER DEFAULT 0"))
if "games_won_baccarat" not in cols: add_cols.append(("games_won_baccarat", "INTEGER DEFAULT 0"))
if "baccarat_net" not in cols: add_cols.append(("baccarat_net", "INTEGER DEFAULT 0"))
if "baccarat_biggest_win" not in cols: add_cols.append(("baccarat_biggest_win", "INTEGER DEFAULT 0"))
for name, decl in add_cols:
add: list[tuple[str, str]] = []
def need(name: str, ddl: str):
"""Queue a column ADD if missing."""
if name not in cols:
add.append((name, ddl))
# Shared
need("free_spins", "INTEGER DEFAULT 0")
need("last_topup", "TEXT DEFAULT NULL")
need("last_daily_ts", "TEXT DEFAULT NULL")
# Blackjack
need("games_played_blackjack", "INTEGER DEFAULT 0")
need("games_won_blackjack", "INTEGER DEFAULT 0")
# Slots
need("games_played_slots", "INTEGER DEFAULT 0")
need("games_won_slots", "INTEGER DEFAULT 0")
need("slots_biggest_win", "INTEGER DEFAULT 0")
# Roulette
need("games_played_roulette", "INTEGER DEFAULT 0")
need("roulette_biggest_win", "INTEGER DEFAULT 0")
need("roulette_net", "INTEGER DEFAULT 0")
# Coin Flip
need("games_played_coinflip", "INTEGER DEFAULT 0")
need("games_won_coinflip", "INTEGER DEFAULT 0")
need("coinflip_biggest_win", "INTEGER DEFAULT 0")
need("coinflip_net", "INTEGER DEFAULT 0")
# Towers
need("games_played_towers", "INTEGER DEFAULT 0")
need("games_won_towers", "INTEGER DEFAULT 0")
# Baccarat
need("games_played_baccarat", "INTEGER DEFAULT 0")
need("games_won_baccarat", "INTEGER DEFAULT 0")
need("baccarat_net", "INTEGER DEFAULT 0")
need("baccarat_biggest_win", "INTEGER DEFAULT 0")
# Hi-Lo
need("games_played_hilo", "INTEGER DEFAULT 0")
need("games_won_hilo", "INTEGER DEFAULT 0")
need("hilo_net", "INTEGER DEFAULT 0")
need("hilo_biggest_win", "INTEGER DEFAULT 0")
# Mines
need("games_played_mines", "INTEGER DEFAULT 0")
need("games_won_mines", "INTEGER DEFAULT 0")
need("mines_net", "INTEGER DEFAULT 0")
need("mines_biggest_win", "INTEGER DEFAULT 0")
# Packs
need("games_played_packs", "INTEGER DEFAULT 0")
need("games_won_packs", "INTEGER DEFAULT 0")
need("packs_net", "INTEGER DEFAULT 0")
need("packs_biggest_win", "INTEGER DEFAULT 0")
# Apply queued column ADDs
for name, decl in add:
cur.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}")
conn.commit()
conn.close()
# Collections table for Packs
cur.execute("""
CREATE TABLE IF NOT EXISTS packs_user_cards (
user_id INTEGER NOT NULL,
card_id TEXT NOT NULL,
qty INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, card_id)
)""")
conn.commit(); conn.close()
# ------------------------------ user & wallet --------------------------------
def ensure_user(user_id: int):
conn = _connect(); cur = conn.cursor()
@@ -96,10 +144,10 @@ def consume_free_spin(user_id: int) -> bool:
cur.execute("UPDATE users SET free_spins = free_spins - 1 WHERE user_id=?", (user_id,))
conn.commit(); conn.close()
return True
conn.close()
return False
conn.close(); return False
# ----------------------------- generic counters ------------------------------
# legacy aggregate
def record_game(user_id: int, won: bool):
ensure_user(user_id)
conn = _connect(); cur = conn.cursor()
@@ -109,7 +157,8 @@ def record_game(user_id: int, won: bool):
cur.execute("UPDATE users SET games_played=games_played+1 WHERE user_id=?", (user_id,))
conn.commit(); conn.close()
# per-game
# ------------------------------ per-game stats -------------------------------
def record_blackjack(user_id: int, won: bool):
ensure_user(user_id)
conn = _connect(); cur = conn.cursor()
@@ -187,7 +236,6 @@ def record_coinflip(user_id: int, bet: int, return_amount: int, won: bool):
record_game(user_id, won)
def record_towers(user_id: int, won: bool):
"""Call with won=True on cashout/full clear; won=False on bomb."""
ensure_user(user_id)
conn = _connect(); cur = conn.cursor()
if won:
@@ -206,13 +254,7 @@ def record_towers(user_id: int, won: bool):
conn.commit(); conn.close()
record_game(user_id, won)
# NEW: Baccarat
def record_baccarat(user_id: int, total_bet: int, total_return: int):
"""
Record a single baccarat hand.
- total_bet: sum of Player+Banker+Tie stakes debited
- total_return: total chips returned (including stake(s) on pushes/wins)
"""
ensure_user(user_id)
net = total_return - total_bet
won = net > 0
@@ -228,45 +270,178 @@ def record_baccarat(user_id: int, total_bet: int, total_return: int):
conn.commit(); conn.close()
record_game(user_id, won)
def record_hilo(user_id: int, bet: int, return_amount: int, won: bool):
"""
Hi-Lo round:
- bet debited up front
- return_amount received on cashout (0 on loss)
- won for W/L if return > bet
"""
ensure_user(user_id)
net = return_amount - bet
conn = _connect(); cur = conn.cursor()
if won:
cur.execute("""
UPDATE users
SET games_played_hilo = games_played_hilo + 1,
games_won_hilo = games_won_hilo + 1,
hilo_net = hilo_net + ?,
hilo_biggest_win = CASE WHEN ? > hilo_biggest_win THEN ? ELSE hilo_biggest_win END
WHERE user_id = ?
""", (net, return_amount, return_amount, user_id))
else:
cur.execute("""
UPDATE users
SET games_played_hilo = games_played_hilo + 1,
hilo_net = hilo_net + ?
WHERE user_id = ?
""", (net, user_id))
conn.commit(); conn.close()
record_game(user_id, won)
def record_mines(user_id: int, bet: int, return_amount: int, won: bool):
ensure_user(user_id)
net = return_amount - bet
conn = _connect(); cur = conn.cursor()
if won:
cur.execute("""
UPDATE users
SET games_played_mines = games_played_mines + 1,
games_won_mines = games_won_mines + 1,
mines_net = mines_net + ?,
mines_biggest_win = CASE WHEN ? > mines_biggest_win THEN ? ELSE mines_biggest_win END
WHERE user_id = ?
""", (net, return_amount, return_amount, user_id))
else:
cur.execute("""
UPDATE users
SET games_played_mines = games_played_mines + 1,
mines_net = mines_net + ?
WHERE user_id = ?
""", (net, user_id))
conn.commit(); conn.close()
record_game(user_id, won)
# ---------------------------------- Packs ------------------------------------
def ensure_packs_tables():
conn = _connect(); cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS packs_user_cards (
user_id INTEGER NOT NULL,
card_id TEXT NOT NULL,
qty INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, card_id)
)""")
conn.commit(); conn.close()
def add_pack_card(user_id: int, card_id: str, qty: int = 1):
ensure_user(user_id)
ensure_packs_tables()
conn = _connect(); cur = conn.cursor()
cur.execute("""
INSERT INTO packs_user_cards (user_id, card_id, qty)
VALUES (?, ?, ?)
ON CONFLICT(user_id, card_id) DO UPDATE SET qty = qty + excluded.qty
""", (user_id, card_id, qty))
conn.commit(); conn.close()
def get_packs_collection(user_id: int) -> dict:
ensure_user(user_id)
ensure_packs_tables()
conn = _connect(); cur = conn.cursor()
cur.execute("SELECT card_id, qty FROM packs_user_cards WHERE user_id = ?", (user_id,))
out = {row[0]: row[1] for row in cur.fetchall()}
conn.close()
return out
def record_packs_open(user_id: int, bet: int, return_amount: int, won: bool):
ensure_user(user_id)
net = return_amount - bet
conn = _connect(); cur = conn.cursor()
if won:
cur.execute("""
UPDATE users
SET games_played_packs = games_played_packs + 1,
games_won_packs = games_won_packs + 1,
packs_net = packs_net + ?,
packs_biggest_win = CASE WHEN ? > packs_biggest_win THEN ? ELSE packs_biggest_win END
WHERE user_id = ?
""", (net, return_amount, return_amount, user_id))
else:
cur.execute("""
UPDATE users
SET games_played_packs = games_played_packs + 1,
packs_net = packs_net + ?
WHERE user_id = ?
""", (net, user_id))
conn.commit(); conn.close()
record_game(user_id, won)
# ------------------------------ queries & misc -------------------------------
def top_cash(limit=10):
conn = _connect(); cur = conn.cursor()
cur.execute("SELECT user_id, cash FROM users ORDER BY cash DESC LIMIT ?", (limit,))
rows = cur.fetchall()
conn.close()
rows = cur.fetchall(); conn.close()
return rows
def user_counts():
conn = _connect(); cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM users")
n = cur.fetchone()[0]
conn.close()
return n
cur.execute("SELECT COUNT(*) FROM users"); n = cur.fetchone()[0]
conn.close(); return n
def get_full_stats(user_id: int):
ensure_user(user_id)
conn = _connect(); cur = conn.cursor()
cur.execute("""
SELECT cash, free_spins, games_played, games_won,
games_played_blackjack, games_won_blackjack,
games_played_slots, games_won_slots,
games_played_roulette, roulette_net,
games_played_coinflip, games_won_coinflip, coinflip_net,
games_played_towers, games_won_towers,
games_played_baccarat, games_won_baccarat, baccarat_net
games_played_baccarat, games_won_baccarat, baccarat_net,
games_played_hilo, games_won_hilo, hilo_net,
games_played_mines, games_won_mines, mines_net,
games_played_packs, games_won_packs, packs_net
FROM users WHERE user_id=?
""", (user_id,))
row = cur.fetchone(); conn.close()
keys = [
"cash","free_spins","games_played","games_won",
"gp_bj","gw_bj",
"gp_slots","gw_slots",
"gp_roulette","roulette_net",
"gp_coin","gw_coin","coin_net",
"gp_towers","gw_towers",
"gp_bac","gw_bac","bac_net",
"gp_hilo","gw_hilo","hilo_net",
"gp_mines","gw_mines","mines_net",
"gp_packs","gw_packs","packs_net",
]
return dict(zip(keys, row))
# -------------------------- faucet / daily / transfer -------------------------
def can_claim_topup(user_id: int, cooldown_minutes: int = 5) -> tuple[bool, int]:
ensure_user(user_id)
conn = _connect(); cur = conn.cursor()
@@ -276,8 +451,7 @@ def can_claim_topup(user_id: int, cooldown_minutes: int = 5) -> tuple[bool, int]
if not last: return True, 0
try: last_dt = datetime.fromisoformat(last)
except Exception: return True, 0
now = datetime.utcnow()
wait = timedelta(minutes=cooldown_minutes)
now = datetime.utcnow(); wait = timedelta(minutes=cooldown_minutes)
if now - last_dt >= wait: return True, 0
left = int((wait - (now - last_dt)).total_seconds())
return False, left
@@ -298,16 +472,14 @@ def daily_cooldown(user_id: int, hours: int = 24) -> tuple[bool, int]:
if not last: return True, 0
try: last_dt = datetime.fromisoformat(last)
except Exception: return True, 0
now = datetime.utcnow()
wait = timedelta(hours=hours)
now = datetime.utcnow(); wait = timedelta(hours=hours)
if now - last_dt >= wait: return True, 0
secs_left = int((wait - (now - last_dt)).total_seconds())
return False, secs_left
def claim_daily(user_id: int, cash_bonus: int, spin_bonus: int):
ensure_user(user_id)
today = str(date.today())
now_str = datetime.utcnow().isoformat(timespec="seconds")
today = str(date.today()); now_str = datetime.utcnow().isoformat(timespec="seconds")
conn = _connect(); cur = conn.cursor()
cur.execute("""
UPDATE users
@@ -337,4 +509,3 @@ def transfer_cash(from_user_id: int, to_user_id: int, amount: int):
conn.rollback(); raise
finally:
conn.close()

View File

@@ -54,14 +54,25 @@ ROULETTE_PAYOUTS = {
"lowhigh": 1, # K=9 (0 loses)
}
# --- Coin Flip constants ---
# Coin flip Tuning
COINFLIP_MIN_CHIP = int(os.getenv("COINFLIP_MIN_CHIP", 10))
COINFLIP_MIN_BET = int(os.getenv("COINFLIP_MIN_BET", COINFLIP_MIN_CHIP))
COINFLIP_MAX_BET = int(os.getenv("COINFLIP_MAX_BET", 50000))
# --- Towers constants ---
# Towers tuning
TOWERS_MIN_BET = int(os.getenv("TOWERS_MIN_BET", 10))
TOWERS_MAX_BET = int(os.getenv("TOWERS_MAX_BET", 50000))
TOWERS_EDGE_PER_STEP = float(os.getenv("TOWERS_EDGE_PER_STEP", 0.02))
# Hi-Lo tuning
HILO_EDGE_PER_STEP = 0.02 # 2% edge per correct step
HILO_MAX_MULT = 50.0 # cap total multiplier to avoid runaway
HILO_MIN_BET = 10
# Mines tuning
MINES_MIN_BET = 10
MINES_EDGE_PER_STEP = 0.015
MINES_MAX_MULT = 1000.0
MINES_CHOICES = [1, 3, 5, 7]