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

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