add hilo, mines, and packs
This commit is contained in:
450
src/cogs/mines.py
Normal file
450
src/cogs/mines.py
Normal 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 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 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** (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)
|
||||
|
||||
# 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 (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)
|
||||
|
||||
# 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 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))
|
||||
Reference in New Issue
Block a user