|
| 1 | +"""Gift box (mystery box) command handlers — money sink.""" |
| 2 | + |
| 3 | +import random |
| 4 | +from datetime import datetime, timedelta |
| 5 | + |
| 6 | +import structlog |
| 7 | +from telegram import Update |
| 8 | +from telegram.ext import CommandHandler, ContextTypes |
| 9 | + |
| 10 | +from app.database.connection import get_db |
| 11 | +from app.database.models import Cooldown, User |
| 12 | +from app.handlers.quest import update_quest_progress |
| 13 | +from app.handlers.shop import SHOP_TITLES, add_user_title, get_user_titles |
| 14 | +from app.utils.decorators import require_registered |
| 15 | +from app.utils.formatters import format_diamonds |
| 16 | + |
| 17 | +logger = structlog.get_logger() |
| 18 | + |
| 19 | +GIFTBOX_COOLDOWN_SECONDS = 120 # 2 min between boxes |
| 20 | + |
| 21 | +# Box tiers |
| 22 | +BOXES = { |
| 23 | + "small": { |
| 24 | + "name": "🎁 Маленький бокс", |
| 25 | + "price": 50, |
| 26 | + "rewards": [ |
| 27 | + {"type": "diamonds", "amount": 10, "label": "10💎", "weight": 30}, |
| 28 | + {"type": "diamonds", "amount": 25, "label": "25💎", "weight": 25}, |
| 29 | + {"type": "diamonds", "amount": 50, "label": "50💎", "weight": 15}, |
| 30 | + {"type": "diamonds", "amount": 100, "label": "100💎", "weight": 5}, |
| 31 | + {"type": "nothing", "amount": 0, "label": "Пусто", "weight": 25}, |
| 32 | + ], |
| 33 | + # EV = 0.30*10 + 0.25*25 + 0.15*50 + 0.05*100 + 0.25*0 = 3+6.25+7.5+5 = 21.75 |
| 34 | + # House edge = (50-21.75)/50 = 56.5% |
| 35 | + }, |
| 36 | + "medium": { |
| 37 | + "name": "🎁 Средний бокс", |
| 38 | + "price": 200, |
| 39 | + "rewards": [ |
| 40 | + {"type": "diamonds", "amount": 50, "label": "50💎", "weight": 25}, |
| 41 | + {"type": "diamonds", "amount": 100, "label": "100💎", "weight": 20}, |
| 42 | + {"type": "diamonds", "amount": 200, "label": "200💎", "weight": 12}, |
| 43 | + {"type": "diamonds", "amount": 500, "label": "500💎", "weight": 3}, |
| 44 | + {"type": "title", "title_id": "shadow", "label": "☠️ Титул «Тень»", "weight": 5}, |
| 45 | + {"type": "title", "title_id": "fire", "label": "🔥 Титул «Огненный»", "weight": 5}, |
| 46 | + {"type": "nothing", "amount": 0, "label": "Пусто", "weight": 30}, |
| 47 | + ], |
| 48 | + # EV diamonds = 0.25*50 + 0.20*100 + 0.12*200 + 0.03*500 = 12.5+20+24+15 = 71.5 |
| 49 | + # Titles worth ~1000-1500 each, 10% chance = ~100-150 EV |
| 50 | + # Total EV ~170-220 vs 200 cost = ~10-15% house edge on average |
| 51 | + }, |
| 52 | + "large": { |
| 53 | + "name": "🎁 Большой бокс", |
| 54 | + "price": 500, |
| 55 | + "rewards": [ |
| 56 | + {"type": "diamonds", "amount": 100, "label": "100💎", "weight": 20}, |
| 57 | + {"type": "diamonds", "amount": 250, "label": "250💎", "weight": 18}, |
| 58 | + {"type": "diamonds", "amount": 500, "label": "500💎", "weight": 10}, |
| 59 | + {"type": "diamonds", "amount": 1000, "label": "1000💎", "weight": 3}, |
| 60 | + {"type": "diamonds", "amount": 2500, "label": "2500💎 ДЖЕКПОТ!", "weight": 1}, |
| 61 | + {"type": "title", "title_id": "legend", "label": "⭐ Титул «Легенда»", "weight": 4}, |
| 62 | + {"type": "title", "title_id": "angel", "label": "😇 Титул «Ангел»", "weight": 4}, |
| 63 | + {"type": "title", "title_id": "devil", "label": "😈 Титул «Дьявол»", "weight": 4}, |
| 64 | + {"type": "nothing", "amount": 0, "label": "Пусто", "weight": 36}, |
| 65 | + ], |
| 66 | + # EV diamonds = 0.20*100 + 0.18*250 + 0.10*500 + 0.03*1000 + 0.01*2500 = 20+45+50+30+25 = 170 |
| 67 | + # Titles worth 2000-2500 each, 12% chance = ~240-300 |
| 68 | + # Total EV ~410-470 vs 500 cost = ~6-18% house edge |
| 69 | + }, |
| 70 | +} |
| 71 | + |
| 72 | + |
| 73 | +def roll_reward(box_type: str) -> dict: |
| 74 | + """Roll a random reward from a box.""" |
| 75 | + box = BOXES[box_type] |
| 76 | + rewards = box["rewards"] |
| 77 | + weights = [r["weight"] for r in rewards] |
| 78 | + return random.choices(rewards, weights=weights, k=1)[0] |
| 79 | + |
| 80 | + |
| 81 | +@require_registered |
| 82 | +async def giftbox_command(update: Update, context: ContextTypes.DEFAULT_TYPE): |
| 83 | + """Handle /giftbox [small|medium|large] command.""" |
| 84 | + if not update.effective_user or not update.message: |
| 85 | + return |
| 86 | + |
| 87 | + user_id = update.effective_user.id |
| 88 | + |
| 89 | + # Parse box type |
| 90 | + if not context.args: |
| 91 | + text = ( |
| 92 | + "🎁 <b>Гифт-боксы</b>\n\n" |
| 93 | + "Открой бокс и получи случайный приз!\n\n" |
| 94 | + f"🎁 /giftbox small — {format_diamonds(BOXES['small']['price'])}\n" |
| 95 | + " Алмазы 10-100 или пусто\n\n" |
| 96 | + f"🎁 /giftbox medium — {format_diamonds(BOXES['medium']['price'])}\n" |
| 97 | + " Алмазы 50-500 или титул\n\n" |
| 98 | + f"🎁 /giftbox large — {format_diamonds(BOXES['large']['price'])}\n" |
| 99 | + " Алмазы 100-2500 или редкий титул\n\n" |
| 100 | + "💡 Можно получить титул из магазина бесплатно!" |
| 101 | + ) |
| 102 | + await update.message.reply_text(text, parse_mode="HTML") |
| 103 | + return |
| 104 | + |
| 105 | + box_type = context.args[0].lower() |
| 106 | + if box_type not in BOXES: |
| 107 | + await update.message.reply_text("❌ Выбери: small, medium или large") |
| 108 | + return |
| 109 | + |
| 110 | + box = BOXES[box_type] |
| 111 | + |
| 112 | + with get_db() as db: |
| 113 | + user = db.query(User).filter(User.telegram_id == user_id).first() |
| 114 | + |
| 115 | + # Check balance |
| 116 | + if user.balance < box["price"]: |
| 117 | + await update.message.reply_text( |
| 118 | + f"❌ Недостаточно алмазов\n\n" |
| 119 | + f"Цена: {format_diamonds(box['price'])}\n" |
| 120 | + f"У тебя: {format_diamonds(user.balance)}" |
| 121 | + ) |
| 122 | + return |
| 123 | + |
| 124 | + # Check cooldown |
| 125 | + cooldown = db.query(Cooldown).filter(Cooldown.user_id == user_id, Cooldown.action == "giftbox").first() |
| 126 | + if cooldown and cooldown.expires_at > datetime.utcnow(): |
| 127 | + remaining = cooldown.expires_at - datetime.utcnow() |
| 128 | + seconds_left = int(remaining.total_seconds()) |
| 129 | + await update.message.reply_text(f"⏰ Следующий бокс через {seconds_left}с") |
| 130 | + return |
| 131 | + |
| 132 | + # Deduct payment |
| 133 | + user.balance -= box["price"] |
| 134 | + |
| 135 | + # Set cooldown |
| 136 | + expires_at = datetime.utcnow() + timedelta(seconds=GIFTBOX_COOLDOWN_SECONDS) |
| 137 | + if cooldown: |
| 138 | + cooldown.expires_at = expires_at |
| 139 | + else: |
| 140 | + db.add(Cooldown(user_id=user_id, action="giftbox", expires_at=expires_at)) |
| 141 | + |
| 142 | + # Roll reward |
| 143 | + reward = roll_reward(box_type) |
| 144 | + |
| 145 | + # Apply reward |
| 146 | + reward_text = "" |
| 147 | + if reward["type"] == "diamonds": |
| 148 | + amount = reward["amount"] |
| 149 | + user.balance += amount |
| 150 | + reward_text = f"💎 +{format_diamonds(amount)}" |
| 151 | + elif reward["type"] == "title": |
| 152 | + title_id = reward["title_id"] |
| 153 | + owned = get_user_titles(user) |
| 154 | + if title_id in owned: |
| 155 | + # Already has title — give diamond equivalent instead |
| 156 | + title_data = SHOP_TITLES.get(title_id, {}) |
| 157 | + refund = title_data.get("price", 500) // 2 |
| 158 | + user.balance += refund |
| 159 | + reward_text = f"🔄 Титул уже есть → +{format_diamonds(refund)}" |
| 160 | + else: |
| 161 | + add_user_title(user, title_id) |
| 162 | + user.active_title = title_id |
| 163 | + reward_text = f"🏆 {reward['label']} — установлен!" |
| 164 | + else: |
| 165 | + reward_text = "💨 Пусто... Повезёт в следующий раз!" |
| 166 | + |
| 167 | + balance = user.balance |
| 168 | + |
| 169 | + # Build message |
| 170 | + text = ( |
| 171 | + f"{box['name']}\n\n" |
| 172 | + f"Потрачено: {format_diamonds(box['price'])}\n\n" |
| 173 | + f"<b>{reward_text}</b>\n\n" |
| 174 | + f"💰 Баланс: {format_diamonds(balance)}" |
| 175 | + ) |
| 176 | + |
| 177 | + await update.message.reply_text(text, parse_mode="HTML") |
| 178 | + |
| 179 | + try: |
| 180 | + update_quest_progress(user_id, "casino") |
| 181 | + except Exception: |
| 182 | + pass |
| 183 | + |
| 184 | + logger.info( |
| 185 | + "Giftbox opened", |
| 186 | + user_id=user_id, |
| 187 | + box_type=box_type, |
| 188 | + reward_type=reward["type"], |
| 189 | + reward_label=reward["label"], |
| 190 | + cost=box["price"], |
| 191 | + ) |
| 192 | + |
| 193 | + |
| 194 | +def register_giftbox_handlers(application): |
| 195 | + """Register giftbox handlers.""" |
| 196 | + application.add_handler(CommandHandler("giftbox", giftbox_command)) |
| 197 | + logger.info("Giftbox handlers registered") |
0 commit comments