445 lines
18 KiB
Python
445 lines
18 KiB
Python
# 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 isn’t your Mines panel.", ephemeral=True)
|
||
if self.view.in_round:
|
||
return await itx.response.send_message("You can’t 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 (A1–E5)"):
|
||
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 isn’t 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 A–E.", ephemeral=True)
|
||
if not row_str.isdigit():
|
||
return await itx.response.send_message("Row must be 1–5.", 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** (A1–E5) 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 (A1–E5) 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 can’t 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 can’t 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 can’t 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))
|