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

445 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/mines.py
import random
import discord
from discord.ext import commands
from typing import Optional, Set, Tuple
from .. import db
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 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)
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)
if idx in self.revealed:
self._busy = False
return
if idx in self.mines:
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
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))