Files
SupaCasino/src/cogs/packs.py
2025-08-29 12:01:50 -05:00

463 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# src/cogs/packs.py
import random
import discord
from discord.ext import commands
from typing import Optional, Dict, List, Tuple
from .. import db
try:
from ..utils.constants import PACK_MIN_BET, PACK_SIZE, PACK_RARITY_WEIGHTS
except Exception:
PACK_MIN_BET = 50
PACK_SIZE = 5
PACK_RARITY_WEIGHTS = {
"Common": 600,
"Uncommon": 250,
"Rare": 100,
"Epic": 40,
"Legendary": 9,
"Mythic": 1,
}
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):
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))