PostgreSQL + async refactoring, WebSocket server, Redis caching, minified assets

This commit is contained in:
2026-07-25 13:31:40 +00:00
parent 47c7f17c90
commit 17da0b6d79
28 changed files with 1840 additions and 3244 deletions
+241 -415
View File
@@ -3,21 +3,16 @@ import random
import math
from datetime import datetime, date
from typing import Dict, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
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
"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 = {
@@ -33,421 +28,321 @@ RARITY_TO_FIELD = {
}
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
"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+ сессий
}
CEILING_SESSION_HISTORY = {0: 5.0, 1: 4.0, 2: 3.5, -1: 3.0}
# Пороги для 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%, удача падает
BUDGET_CONSUMPTION_RATE = 0.001
BUDGET_REGEN_PER_OPEN = 0.5
BUDGET_LOW_THRESHOLD = 20.0
# Комбек
COMEBACK_TRIGGER_COUNT = 5 # 5+ проигрышей подряд → комбек
COMEBACK_TRIGGER_VALUE_RATIO = 0.3 # потеря >30% от total_deposited
COMEBACK_OPENINGS = 5 # комбек на 5 открытий
COMEBACK_MULTIPLIER_MAX = 3.0 # макс множитель удачи в комбеке
COMEBACK_TRIGGER_COUNT = 5
COMEBACK_TRIGGER_VALUE_RATIO = 0.3
COMEBACK_OPENINGS = 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₽/день = холодный
HOT_THRESHOLD_SPENT = 10000
HOT_THRESHOLD_WITHDRAWAL = 0.1
COLD_DAYS_INACTIVE = 7
COLD_SPENT_PER_DAY = 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()
async def _rpu_commit_or_flush_async(db: AsyncSession):
if _BATCH_MODE:
await db.flush()
else:
await 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)
async def get_user_rpu(db: AsyncSession, user_id: int) -> UserRPU:
result = await db.execute(select(UserRPU).where(UserRPU.user_id == user_id))
rpu = result.scalar_one_or_none()
if not rpu:
rpu = UserRPU(user_id=user_id)
db.add(rpu)
await _rpu_commit_or_flush_async(db)
await db.refresh(rpu)
return rpu
def regen_luck_budget(db: Session, user_id: int):
rpu = get_user_rpu(db, user_id)
async def check_and_reset_session(db: AsyncSession, user_id: int):
rpu = await 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
await _rpu_commit_or_flush_async(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
await _rpu_commit_or_flush_async(db)
async def calculate_ceiling(db: AsyncSession, user_id: int) -> float:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return CEILING_MIN
total_dep = max(0.0, user.total_deposited)
rpu = await 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)
mult = CEILING_SESSION_HISTORY.get(session_count, CEILING_SESSION_HISTORY[-1])
mult *= rpu.ceiling_multiplier
ceiling = total_dep * mult
return max(CEILING_MIN, ceiling)
async def get_total_inventory_value(db: AsyncSession, user_id: int) -> float:
result = await db.execute(
select(func.coalesce(func.sum(InventoryItem.price_rub), 0))
.where(InventoryItem.user_id == user_id)
)
return float(result.scalar() or 0)
async def get_user_total_value(db: AsyncSession, user_id: int) -> float:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
balance = user.balance if user else 0
inv = await get_total_inventory_value(db, user_id)
return balance + inv
async def get_ceiling_luck_factor(db: AsyncSession, user_id: int) -> float:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return 1.0
ceiling = await calculate_ceiling(db, user_id)
inv_value = await 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
async def check_ceiling_breach(db: AsyncSession, user_id: int, item_price: float, case_price: float) -> bool:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return False
ceiling = await calculate_ceiling(db, user_id)
total_value = user.balance + await get_total_inventory_value(db, user_id)
if total_value < ceiling:
return False
if item_price < ceiling * 0.5:
return False
return True
async def apply_ceiling_breach(db: AsyncSession, user_id: int, item_price: float):
rpu = await 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
await _rpu_commit_or_flush_async(db)
async def apply_ceiling_withdrawal_penalty(db: AsyncSession, user_id: int):
rpu = await get_user_rpu(db, user_id)
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
await _rpu_commit_or_flush_async(db)
async def consume_luck_budget(db: AsyncSession, user_id: int, item_price: float):
rpu = await get_user_rpu(db, user_id)
rpu.luck_budget = max(0.0, rpu.luck_budget - item_price * BUDGET_CONSUMPTION_RATE)
await _rpu_commit_or_flush_async(db)
async def regen_luck_budget(db: AsyncSession, user_id: int):
rpu = await get_user_rpu(db, user_id)
rpu.luck_budget = min(BUDGET_MAX, rpu.luck_budget + BUDGET_REGEN_PER_OPEN)
_rpu_commit_or_flush(db)
await _rpu_commit_or_flush_async(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()
async def update_comeback_tracking(db: AsyncSession, user_id: int, spent: float, received_value: float):
rpu = await get_user_rpu(db, user_id)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
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)
await _rpu_commit_or_flush_async(db)
def apply_comeback(db: Session, user_id: int) -> float:
"""
Применяет комбек-множитель если активен.
Возвращает множитель для следующего открытия.
"""
rpu = get_user_rpu(db, user_id)
async def apply_comeback(db: AsyncSession, user_id: int) -> float:
rpu = await 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)
await _rpu_commit_or_flush_async(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()
async def analyze_hot_cold(db: AsyncSession, user_id: int) -> Tuple[bool, bool, float]:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
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)
rpu = await 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
is_cold = True
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)
await _rpu_commit_or_flush_async(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()
async def check_withdrawal_penalty(db: AsyncSession, user_id: int):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
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 = await 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)
await _rpu_commit_or_flush_async(db)
# ── V2: Enhanced get_adjusted_weights ──
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_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
"""
Получает веса редкостей с учетом РПУ v2:
- сессии, потолок, luck_budget, комбек, промо-фаза, hot/cold
В batch-режиме результат кэшируется — повторные вызовы с тем же user_id
возвращают закешированное значение без DB-запросов.
"""
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 "💀 Проклятый"
async def get_adjusted_weights(db: AsyncSession, user_id: int) -> Dict[str, float]:
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)
await check_and_reset_session(db, user_id)
rpu = await get_user_rpu(db, user_id)
level = calculate_rpu_level(rpu)
is_hot, is_cold, wd_ratio = await analyze_hot_cold(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
comeback_mult = await apply_comeback(db, user_id)
budget_factor = get_budget_factor(rpu)
# Ceiling factor — не блокирует, а лишь приглушает удачу
ceiling_factor = get_ceiling_luck_factor(db, user_id)
ceiling_factor = await get_ceiling_luck_factor(db, user_id)
adjusted = {}
for rarity, base_weight in BASE_RARITY_WEIGHTS.items():
@@ -455,29 +350,18 @@ def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
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
if level < 50:
luck_factor = 1.0 + ((50 - level) / 50) * rarity_index * 0.3
else:
luck_factor = 1.0 - ((rpu_level - 50) / 50) * rarity_index * 0.2
luck_factor = 1.0 - ((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))
@@ -488,86 +372,40 @@ def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
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)
async def auto_adjust_rpu(db: AsyncSession, user_id: int):
rpu = await get_user_rpu(db, user_id)
if not rpu.auto_adjust:
return
user = db.query(User).filter(User.id == user_id).first()
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
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))
setattr(rpu, r, min(2.0, getattr(rpu, r) + 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))
setattr(rpu, r, max(0.3, getattr(rpu, r) - 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))
setattr(rpu, r, max(0.2, getattr(rpu, r) - 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)
@@ -575,19 +413,17 @@ def auto_adjust_rpu(db: Session, user_id: int):
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)))
setattr(rpu, field, max(0.1, min(3.0, getattr(rpu, field))))
rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier))
rpu.last_adjustment = datetime.utcnow()
_rpu_commit_or_flush(db)
await _rpu_commit_or_flush_async(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()
async def update_rpu_stats(db: AsyncSession, user_id: int, spent: float, opened: int = 1,
received_value: float = 0.0, rarity: str = None, is_rare: bool = False):
rpu = await get_user_rpu(db, user_id)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
rpu.total_spent += spent
rpu.total_opened += opened
@@ -605,45 +441,35 @@ def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1,
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)
await consume_luck_budget(db, user_id, received_value)
await regen_luck_budget(db, user_id)
await update_comeback_tracking(db, user_id, spent, received_value)
# ── 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)
if await check_ceiling_breach(db, user_id, received_value, spent):
await apply_ceiling_breach(db, user_id, received_value)
# ── RPU v2: Hot/Cold ──
analyze_hot_cold(db, user_id)
_rpu_commit_or_flush(db)
await analyze_hot_cold(db, user_id)
await _rpu_commit_or_flush_async(db)
if rpu.auto_adjust:
auto_adjust_rpu(db, user_id)
await 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()
async def get_rpu_v2_info(db: AsyncSession, user_id: int) -> dict:
await check_and_reset_session(db, user_id)
rpu = await get_user_rpu(db, user_id)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return {}
ceiling = calculate_ceiling(db, user_id)
inv_value = get_total_inventory_value(db, user_id)
ceiling = await calculate_ceiling(db, user_id)
inv_value = await 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)
is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id)
session_count = 0
if user.created_at:
session_count = max(0, (datetime.utcnow() - user.created_at).days)
session_count = max(0, (datetime.utcnow() - user.created_at).days) if user.created_at else 0
return {
"rpu_level": round(calculate_rpu_level(rpu)),