import json import random import math from datetime import datetime, date from typing import Dict, Optional, Tuple from sqlalchemy.orm import Session from database import User, UserRPU, InventoryItem from promo_phase import get_promo_effect_for_user, get_current_phase # Стандартные веса редкостей BASE_RARITY_WEIGHTS = { "Consumer Grade": 10000, "Industrial Grade": 5000, "Mil-Spec": 2500, "Mil-Spec Grade": 2500, "Restricted": 625, "Classified": 125, "Covert": 25, "Rare Special Item": 5, "Extraordinary": 5 } RARITY_TO_FIELD = { "Consumer Grade": "consumer_multiplier", "Industrial Grade": "industrial_multiplier", "Mil-Spec": "mil_spec_multiplier", "Mil-Spec Grade": "mil_spec_multiplier", "Restricted": "restricted_multiplier", "Classified": "classified_multiplier", "Covert": "covert_multiplier", "Rare Special Item": "rare_special_multiplier", "Extraordinary": "rare_special_multiplier" } RARITY_RANK = { "Consumer Grade": 0, "Industrial Grade": 1, "Mil-Spec": 2, "Mil-Spec Grade": 2, "Restricted": 3, "Classified": 4, "Covert": 5, "Rare Special Item": 6, "Extraordinary": 6 } # Пороги потолка CEILING_BASE_MULTIPLIER = 3.0 CEILING_MIN = 1000.0 CEILING_SESSION_HISTORY = { 0: 5.0, # первая сессия 1: 4.0, # вторая 2: 3.5, # третья -1: 3.0 # 3+ сессий } # Пороги для luck_budget BUDGET_MAX = 100.0 BUDGET_CONSUMPTION_RATE = 0.001 # 0.1% от цены предмета BUDGET_REGEN_PER_OPEN = 0.5 # +0.5% за каждое открытие BUDGET_LOW_THRESHOLD = 20.0 # если budget < 20%, удача падает # Комбек COMEBACK_TRIGGER_COUNT = 5 # 5+ проигрышей подряд → комбек COMEBACK_TRIGGER_VALUE_RATIO = 0.3 # потеря >30% от total_deposited COMEBACK_OPENINGS = 5 # комбек на 5 открытий COMEBACK_MULTIPLIER_MAX = 3.0 # макс множитель удачи в комбеке # Hot/Cold HOT_THRESHOLD_SPENT = 10000 # потратил >10k HOT_THRESHOLD_WITHDRAWAL = 0.1 # вывел <10% от депозитов COLD_DAYS_INACTIVE = 7 # 7 дней неактивен = холодный COLD_SPENT_PER_DAY = 100 # <100₽/день = холодный def get_user_rpu(db: Session, user_id: int) -> UserRPU: rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first() if not rpu: rpu = UserRPU(user_id=user_id) db.add(rpu) _rpu_commit_or_flush(db) db.refresh(rpu) return rpu # ── Session Management ── def check_and_reset_session(db: Session, user_id: int): """ Проверяет, не пора ли сбросить сессию (каждый календарный день). """ rpu = get_user_rpu(db, user_id) today = date.today() if rpu.session_reset_date is None: rpu.session_reset_date = datetime.utcnow() rpu.luck_budget = BUDGET_MAX rpu.session_spent = 0 rpu.session_won = 0 rpu.ceiling_break_session = 0 rpu.comeback_active = False rpu.comeback_openings_left = 0 rpu.comeback_multiplier = 1.0 rpu.consecutive_loss_count = 0 rpu.consecutive_loss_value = 0.0 _rpu_commit_or_flush(db) return last_reset = rpu.session_reset_date.date() if last_reset < today: rpu.session_reset_date = datetime.utcnow() rpu.luck_budget = BUDGET_MAX rpu.session_spent = 0 rpu.session_won = 0 rpu.ceiling_break_session = 0 rpu.comeback_active = False rpu.comeback_openings_left = 0 rpu.comeback_multiplier = 1.0 rpu.consecutive_loss_count = 0 rpu.consecutive_loss_value = 0.0 _rpu_commit_or_flush(db) # ── Ceiling (Потолок) ── def calculate_ceiling(db: Session, user_id: int) -> float: """ Потолок = total_deposited * mult. mult зависит от количества сессий на сайте. """ user = db.query(User).filter(User.id == user_id).first() if not user: return CEILING_MIN total_dep = max(0.0, user.total_deposited) # Считаем, сколько прошло сессий (дней с первой транзакции) rpu = get_user_rpu(db, user_id) session_count = 0 if rpu.session_reset_date and user.created_at: days_since = (datetime.utcnow() - user.created_at).days session_count = max(0, days_since) if session_count == 0: mult = CEILING_SESSION_HISTORY[0] elif session_count == 1: mult = CEILING_SESSION_HISTORY[1] elif session_count == 2: mult = CEILING_SESSION_HISTORY[2] else: mult = CEILING_SESSION_HISTORY[-1] # Учитываем ceiling_multiplier (временные модификаторы) mult *= rpu.ceiling_multiplier ceiling = total_dep * mult return max(CEILING_MIN, ceiling) def get_total_inventory_value(db: Session, user_id: int) -> float: """Суммарная стоимость инвентаря пользователя""" items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all() return sum(item.price_rub or 0 for item in items) def get_user_total_value(db: Session, user_id: int) -> float: """Баланс + инвентарь""" user = db.query(User).filter(User.id == user_id).first() balance = user.balance if user else 0 inv = get_total_inventory_value(db, user_id) return balance + inv def get_ceiling_luck_factor(db: Session, user_id: int) -> float: """ Возвращает множитель удачи на основе близости к потолку (0.3–1.0). Чем ближе к потолку — тем меньше множитель. Не блокирует открытие, а лишь делает удачу чуть хуже. """ user = db.query(User).filter(User.id == user_id).first() if not user: return 1.0 ceiling = calculate_ceiling(db, user_id) inv_value = get_total_inventory_value(db, user_id) withdrawn = user.total_withdrawn or 0 extracted_value = inv_value + withdrawn if ceiling <= 0: return 1.0 ratio = extracted_value / ceiling if ratio < 0.8: return 1.0 if ratio >= 1.0: return 0.3 return 1.0 - (ratio - 0.8) / 0.2 * 0.7 def check_ceiling_breach(db: Session, user_id: int, item_price: float, case_price: float) -> bool: """ Проверяет, пробивает ли предмет потолок. Если предмет дороже 50% от потолка — пробитие. """ user = db.query(User).filter(User.id == user_id).first() if not user: return False ceiling = calculate_ceiling(db, user_id) total_value = user.balance + get_total_inventory_value(db, user_id) # Если инвентарь уже под потолком — не пробиваем if total_value < ceiling: return False # Сам предмет должен быть дорогим if item_price < ceiling * 0.5: return False return True def apply_ceiling_breach(db: Session, user_id: int, item_price: float): """ Пробитие потолка: расширяем потолок на 50% на остаток сессии. """ rpu = get_user_rpu(db, user_id) rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5) rpu.ceiling_break_count += 1 rpu.ceiling_break_session += 1 _rpu_commit_or_flush(db) def apply_ceiling_withdrawal_penalty(db: Session, user_id: int): """ При большом выводе — скручиваем потолок на следующих N сессий. """ rpu = get_user_rpu(db, user_id) rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7) _rpu_commit_or_flush(db) # ── Luck Budget ── # Флаг: если True — sub-функции делают flush вместо commit. # Устанавливается в True на время горячего пути (case_open, upgrade). _BATCH_MODE = False # Кэш adjusted_weights на время batch-операции # Ключ: user_id, значение: результат get_adjusted_weights _BATCH_WEIGHTS_CACHE = {} def _rpu_commit_or_flush(db): if _BATCH_MODE: db.flush() else: db.commit() def _batch_cache_get(user_id: int): """Возвращает кэшированные adjusted_weights для batch или None.""" if _BATCH_MODE and user_id in _BATCH_WEIGHTS_CACHE: return _BATCH_WEIGHTS_CACHE[user_id] return None def _batch_cache_set(user_id: int, weights: dict): """Сохраняет adjusted_weights в кэш batch.""" if _BATCH_MODE: _BATCH_WEIGHTS_CACHE[user_id] = weights def _batch_cache_clear(user_id: int = None): """Очищает кэш batch (по user_id или полностью).""" if user_id is not None: _BATCH_WEIGHTS_CACHE.pop(user_id, None) else: _BATCH_WEIGHTS_CACHE.clear() def consume_luck_budget(db: Session, user_id: int, item_price: float): rpu = get_user_rpu(db, user_id) consumption = item_price * BUDGET_CONSUMPTION_RATE rpu.luck_budget = max(0.0, rpu.luck_budget - consumption) _rpu_commit_or_flush(db) def regen_luck_budget(db: Session, user_id: int): rpu = get_user_rpu(db, user_id) rpu.luck_budget = min(BUDGET_MAX, rpu.luck_budget + BUDGET_REGEN_PER_OPEN) _rpu_commit_or_flush(db) def get_budget_factor(rpu: UserRPU) -> float: """ Возвращает множитель на основе luck_budget. Если budget низкий (<20%) — удача падает. """ if rpu.luck_budget >= BUDGET_LOW_THRESHOLD: return 1.0 return max(0.3, rpu.luck_budget / BUDGET_LOW_THRESHOLD) # ── Comeback System ── def update_comeback_tracking(db: Session, user_id: int, spent: float, received_value: float): """ Отслеживает проигрыши для триггера комбека. """ rpu = get_user_rpu(db, user_id) user = db.query(User).filter(User.id == user_id).first() if not user: return # Если проиграл (получил меньше чем потратил) if received_value < spent * 0.8: rpu.consecutive_loss_count += 1 rpu.consecutive_loss_value += (spent - received_value) else: rpu.consecutive_loss_count = 0 rpu.consecutive_loss_value = 0.0 # Проверяем триггер комбека total_dep = max(1.0, user.total_deposited) loss_ratio = rpu.consecutive_loss_value / total_dep if (rpu.consecutive_loss_count >= COMEBACK_TRIGGER_COUNT or loss_ratio >= COMEBACK_TRIGGER_VALUE_RATIO): if not rpu.comeback_active: rpu.comeback_active = True rpu.comeback_openings_left = COMEBACK_OPENINGS rpu.comeback_multiplier = COMEBACK_MULTIPLIER_MAX _rpu_commit_or_flush(db) def apply_comeback(db: Session, user_id: int) -> float: """ Применяет комбек-множитель если активен. Возвращает множитель для следующего открытия. """ rpu = get_user_rpu(db, user_id) if not rpu.comeback_active or rpu.comeback_openings_left <= 0: return 1.0 mult = rpu.comeback_multiplier rpu.comeback_openings_left -= 1 if rpu.comeback_openings_left <= 0: rpu.comeback_active = False rpu.comeback_multiplier = 1.0 rpu.consecutive_loss_count = 0 rpu.consecutive_loss_value = 0.0 _rpu_commit_or_flush(db) return mult # ── Hot/Cold Analysis ── def analyze_hot_cold(db: Session, user_id: int) -> Tuple[bool, bool, float]: """ Анализирует пользователя: горячий / холодный. Возвращает (is_hot, is_cold, withdrawal_ratio). """ user = db.query(User).filter(User.id == user_id).first() if not user: return False, True, 0.0 total_dep = max(0.0, user.total_deposited) total_wd = max(0.0, user.total_withdrawn) wd_ratio = total_wd / max(1.0, total_dep) # Горячий: много потратил, почти не выводил is_hot = (total_dep >= HOT_THRESHOLD_SPENT and wd_ratio < HOT_THRESHOLD_WITHDRAWAL) # Холодный: давно не был или мало активность rpu = get_user_rpu(db, user_id) is_cold = False if rpu.last_activity_date: days_since = (datetime.utcnow() - rpu.last_activity_date).days if days_since >= COLD_DAYS_INACTIVE: is_cold = True else: is_cold = True # Новичок без активности # Считаем hot_score days_active = max(1, (datetime.utcnow() - user.created_at).days) spent_per_day = total_dep / days_active if spent_per_day < COLD_SPENT_PER_DAY and total_dep > 0: is_cold = True hot_score = total_dep - (total_wd * 2) rpu.hot_score = hot_score rpu.last_activity_date = datetime.utcnow() _rpu_commit_or_flush(db) return is_hot, is_cold, wd_ratio # ── Withdrawal Tax (Открутка при выводе) ── def check_withdrawal_penalty(db: Session, user_id: int): """ Если пользователь вывел >50% от депозитов — скручиваем удачу и потолок. """ user = db.query(User).filter(User.id == user_id).first() if not user: return total_dep = max(1.0, user.total_deposited) total_wd = max(0.0, user.total_withdrawn) wd_ratio = total_wd / total_dep if wd_ratio > 0.5: rpu = get_user_rpu(db, user_id) rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - wd_ratio * 0.3) rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7) _rpu_commit_or_flush(db) # ── V2: Enhanced get_adjusted_weights ── def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]: """ Получает веса редкостей с учетом РПУ v2: - сессии, потолок, luck_budget, комбек, промо-фаза, hot/cold В batch-режиме результат кэшируется — повторные вызовы с тем же user_id возвращают закешированное значение без DB-запросов. """ cached = _batch_cache_get(user_id) if cached is not None: return cached check_and_reset_session(db, user_id) rpu = get_user_rpu(db, user_id) # Промо-фаза promo = get_promo_effect_for_user(user_id, rpu.hot_score, wd_ratio, is_cold) # Комбек comeback_mult = apply_comeback(db, user_id) # Budget factor budget_factor = get_budget_factor(rpu) # Ceiling factor — не блокирует, а лишь приглушает удачу ceiling_factor = get_ceiling_luck_factor(db, user_id) adjusted = {} for rarity, base_weight in BASE_RARITY_WEIGHTS.items(): field = RARITY_TO_FIELD.get(rarity) base_multiplier = getattr(rpu, field, 1.0) if field else 1.0 rarity_index = RARITY_RANK.get(rarity, 0) if rpu_level < 50: luck_factor = 1.0 + ((50 - rpu_level) / 50) * rarity_index * 0.3 else: luck_factor = 1.0 - ((rpu_level - 50) / 50) * rarity_index * 0.2 luck_factor = max(0.1, min(3.0, luck_factor)) # Применяем все множители final = base_multiplier * rpu.luck_multiplier * luck_factor # Промо-фаза final *= (1.0 + promo["luck_modifier"]) # Budget final *= budget_factor # Комбек final *= comeback_mult # Потолок — не блокирует, а лишь приглушает удачу final *= ceiling_factor # Редкие предметы получают доп модификатор от промо if rarity_index >= 3: final *= (1.0 + promo.get("rare_chance_modifier", 0)) final = max(0.05, min(5.0, final)) adjusted[rarity] = base_weight * final _batch_cache_set(user_id, adjusted) return adjusted def calculate_rpu_level(rpu: UserRPU) -> float: multipliers = [ rpu.consumer_multiplier, rpu.industrial_multiplier, rpu.mil_spec_multiplier, rpu.restricted_multiplier, rpu.classified_multiplier, rpu.covert_multiplier, rpu.rare_special_multiplier ] avg = sum(multipliers) / len(multipliers) if avg >= 1.0: level = 50 - ((avg - 1.0) / 2.0) * 100 else: level = 50 + ((1.0 - avg) / 0.9) * 50 return max(0, min(100, level)) def get_rpu_description(level: float) -> str: if level < 20: return "🍀 Очень везучий" elif level < 35: return "😊 Везучий" elif level < 45: return "🙂 Немного везучий" elif level < 55: return "😐 Обычный" elif level < 70: return "🙁 Немного невезучий" elif level < 85: return "😟 Невезучий" else: return "💀 Проклятый" def auto_adjust_rpu(db: Session, user_id: int): rpu = get_user_rpu(db, user_id) if not rpu.auto_adjust: return user = db.query(User).filter(User.id == user_id).first() if not user: return if rpu.total_opened > 0 and rpu.total_spent > 0: roi = rpu.total_value_received / rpu.total_spent if roi < 0.4: boost = (0.4 - roi) * 2 rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost) for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: v = getattr(rpu, r) setattr(rpu, r, min(2.0, v + boost * 0.3)) elif roi > 2.0: penalty = (roi - 2.0) * 0.5 rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty) for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: v = getattr(rpu, r) setattr(rpu, r, max(0.3, v - penalty * 0.5)) streak = rpu.current_streak if streak <= -5: boost = min(0.3, abs(streak) * 0.03) rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost) rpu.classified_multiplier = min(2.0, rpu.classified_multiplier + boost * 0.5) rpu.covert_multiplier = min(1.8, rpu.covert_multiplier + boost * 0.4) rpu.rare_special_multiplier = min(1.5, rpu.rare_special_multiplier + boost * 0.3) if streak >= 5: penalty = min(0.2, streak * 0.02) rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty) for r in ['classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: v = getattr(rpu, r) setattr(rpu, r, max(0.2, v - penalty * 0.5)) if rpu.total_opened > 50: rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05) rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03) for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier', 'restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: value = getattr(rpu, field) setattr(rpu, field, max(0.1, min(3.0, value))) rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier)) rpu.last_adjustment = datetime.utcnow() _rpu_commit_or_flush(db) def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1, received_value: float = 0.0, rarity: str = None, is_rare: bool = False): rpu = get_user_rpu(db, user_id) user = db.query(User).filter(User.id == user_id).first() rpu.total_spent += spent rpu.total_opened += opened rpu.total_value_received += received_value rpu.session_spent += spent rpu.session_won += received_value if is_rare or received_value > spent * 3: rpu.current_streak = min(20, rpu.current_streak + 1) else: rpu.current_streak = max(-20, rpu.current_streak - 1) if rpu.current_streak > rpu.best_streak: rpu.best_streak = rpu.current_streak if rpu.current_streak < rpu.worst_streak: rpu.worst_streak = rpu.current_streak # ── RPU v2: Luck Budget ── consume_luck_budget(db, user_id, received_value) regen_luck_budget(db, user_id) # ── RPU v2: Comeback tracking ── update_comeback_tracking(db, user_id, spent, received_value) # ── RPU v2: Ceiling breach check ── if user and is_rare and received_value > 0: if check_ceiling_breach(db, user_id, received_value, spent): apply_ceiling_breach(db, user_id, received_value) # ── RPU v2: Hot/Cold ── analyze_hot_cold(db, user_id) _rpu_commit_or_flush(db) if rpu.auto_adjust: auto_adjust_rpu(db, user_id) def get_rpu_v2_info(db: Session, user_id: int) -> dict: """ Возвращает полную информацию о RPU v2 для профиля/админки. """ check_and_reset_session(db, user_id) rpu = get_user_rpu(db, user_id) user = db.query(User).filter(User.id == user_id).first() if not user: return {} ceiling = calculate_ceiling(db, user_id) inv_value = get_total_inventory_value(db, user_id) total_value = user.balance + inv_value is_hot, is_cold, wd_ratio = analyze_hot_cold(db, user_id) session_count = 0 if user.created_at: session_count = max(0, (datetime.utcnow() - user.created_at).days) return { "rpu_level": round(calculate_rpu_level(rpu)), "description": get_rpu_description(calculate_rpu_level(rpu)), "luck_multiplier": rpu.luck_multiplier, "luck_budget": round(rpu.luck_budget, 1), "ceiling": round(ceiling, 2), "ceiling_multiplier": round(rpu.ceiling_multiplier, 2), "ceiling_breach_total": rpu.ceiling_break_count, "ceiling_breach_session": rpu.ceiling_break_session, "total_value": round(total_value, 2), "balance": round(user.balance, 2), "inventory_value": round(inv_value, 2), "total_deposited": round(user.total_deposited, 2), "total_withdrawn": round(user.total_withdrawn, 2), "session": { "number": session_count, "spent": round(rpu.session_spent, 2), "won": round(rpu.session_won, 2), }, "comeback": { "active": rpu.comeback_active, "openings_left": rpu.comeback_openings_left, "multiplier": round(rpu.comeback_multiplier, 2), }, "streak": { "current": rpu.current_streak, "best": rpu.best_streak, "worst": rpu.worst_streak, }, "hot_cold": { "is_hot": is_hot, "is_cold": is_cold, "hot_score": round(rpu.hot_score, 2), "withdrawal_ratio": round(wd_ratio, 3), }, "promo_phase": get_current_phase(), }