# 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))