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
+1
View File
@@ -5,3 +5,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.DS_Store .DS_Store
venv/
+680 -2440
View File
File diff suppressed because it is too large Load Diff
+66 -58
View File
@@ -3,127 +3,135 @@ from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status, Request from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db, User from database import get_async_db, get_db, User
import os import os
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production") SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey-change-in-production")
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def decode_token(token: str) -> Optional[dict]: def decode_token(token: str) -> Optional[dict]:
"""Декодирует JWT токен""" if not token:
if not token: # <-- Добавлена проверка
return None return None
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError: except JWTError:
return None return None
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
return pwd_context.hash(password) return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy() to_encode = data.copy()
if expires_delta: expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user_from_token(token: str, db: Session) -> Optional[User]:
"""Получает пользователя из JWT токена""" async def get_current_user_from_token(token: str, db) -> Optional[User]:
if not token: # <-- Добавлена проверка if not token:
return None return None
payload = decode_token(token) payload = decode_token(token)
if not payload: if not payload:
return None return None
username = payload.get("sub") username = payload.get("sub")
if not username: if not username:
return None return None
if isinstance(db, AsyncSession):
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
return db.query(User).filter(User.username == username).first()
user = db.query(User).filter(User.username == username).first()
return user
async def get_current_user_optional(request: Request, db: Session = Depends(get_db)) -> Optional[User]: async def get_current_user_optional(request: Request, db=Depends(get_async_db)) -> Optional[User]:
token = request.cookies.get("access_token") token = request.cookies.get("access_token")
if not token: # <-- Исправлено: сразу возвращаем None if not token:
return None return None
if token.startswith("Bearer "): if token.startswith("Bearer "):
token = token[7:] token = token[7:]
return await get_current_user_from_token(token, db) return await get_current_user_from_token(token, db)
async def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
async def get_current_user(request: Request, db=Depends(get_async_db)) -> User:
token = request.cookies.get("access_token") token = request.cookies.get("access_token")
if not token: if not token:
raise HTTPException( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if token.startswith("Bearer "): if token.startswith("Bearer "):
token = token[7:] token = token[7:]
user = await get_current_user_from_token(token, db) user = await get_current_user_from_token(token, db)
if user is None: if user is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return user return user
async def get_current_admin(request: Request, db=Depends(get_async_db)) -> User:
user = await get_current_user(request, db)
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
# ─── Sync auth deps (for non-converted endpoints) ──────────────────────────
async def get_current_user_sync(request: Request, db=Depends(get_db)) -> User:
token = request.cookies.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
if token.startswith("Bearer "):
token = token[7:]
user = db.query(User).filter(User.username == decode_token(token).get("sub")).first() if decode_token(token) else None
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
return user
async def get_current_user_optional_sync(request: Request, db=Depends(get_db)) -> Optional[User]:
token = request.cookies.get("access_token")
if not token:
return None
if token.startswith("Bearer "):
token = token[7:]
payload = decode_token(token)
if not payload:
return None
return db.query(User).filter(User.username == payload.get("sub")).first()
def create_user(db: Session, username: str, password: str) -> User: def create_user(db: Session, username: str, password: str) -> User:
hashed_password = get_password_hash(password) hashed = get_password_hash(password)
user = User(username=username, hashed_password=hashed_password, balance=1000.0) user = User(username=username, hashed_password=hashed, balance=1000.0)
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return user return user
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]: def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
user = db.query(User).filter(User.username == username).first() user = db.query(User).filter(User.username == username).first()
if not user: if not user or not verify_password(password, user.hashed_password):
return None
if not verify_password(password, user.hashed_password):
return None return None
return user return user
def is_item_craftable(item_id: int) -> bool:
"""Проверяет, можно ли использовать предмет в контракте"""
from backend import get_item, is_final_in_collection
def is_item_craftable(item_id: int) -> bool:
from backend import get_item, is_final_in_collection
item = get_item(item_id) item = get_item(item_id)
if not item: if not item:
return False return False
if not item.get("is_craftable", True): if not item.get("is_craftable", True):
return False return False
if is_final_in_collection(item): if is_final_in_collection(item):
return False return False
return True return True
async def get_current_admin(request: Request, db: Session = Depends(get_db)) -> User:
"""Проверяет, что пользователь - админ"""
user = await get_current_user(request, db)
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return user
+67
View File
@@ -0,0 +1,67 @@
import os
import json
import asyncio
from typing import Optional, Any, Callable, Awaitable
from datetime import timedelta
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CACHE_TTL = int(os.getenv("CACHE_TTL", "300"))
_redis_pool = None
_lock = asyncio.Lock()
async def get_redis():
global _redis_pool
if _redis_pool is None:
import aioredis
async with _lock:
if _redis_pool is None:
_redis_pool = await aioredis.from_url(REDIS_URL, decode_responses=True)
return _redis_pool
async def cache_get(key: str) -> Optional[Any]:
try:
r = await get_redis()
val = await r.get(key)
if val:
return json.loads(val)
except Exception:
pass
return None
async def cache_set(key: str, value: Any, ttl: int = CACHE_TTL):
try:
r = await get_redis()
await r.setex(key, ttl, json.dumps(value, default=str))
except Exception:
pass
async def cache_delete(key: str):
try:
r = await get_redis()
await r.delete(key)
except Exception:
pass
async def cache_get_or_set(key: str, factory: Callable[[], Awaitable[Any]], ttl: int = CACHE_TTL) -> Any:
cached = await cache_get(key)
if cached is not None:
return cached
value = await factory()
await cache_set(key, value, ttl)
return value
async def cache_invalidate_pattern(pattern: str):
try:
r = await get_redis()
keys = await r.keys(pattern)
if keys:
await r.delete(*keys)
except Exception:
pass
+176 -220
View File
@@ -1,27 +1,59 @@
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text, text import os
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from datetime import datetime
import json import json
from pathlib import Path from pathlib import Path
from datetime import datetime
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Generator, Optional
DATABASE_URL = "sqlite:///./cs2_simulator.db" from sqlalchemy import (
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) create_engine, Column, Integer, String, Float, Boolean,
DateTime, ForeignKey, Text, text, inspect, Index
)
from sqlalchemy.orm import sessionmaker, relationship, DeclarativeBase
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
# ─── Config ──────────────────────────────────────────────────────────────────
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./cs2_simulator.db")
ASYNC_DATABASE_URL = os.getenv("ASYNC_DATABASE_URL", "sqlite+aiosqlite:///./cs2_simulator.db")
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
USE_ASYNC = os.getenv("USE_ASYNC", "0") == "1" or "postgresql" in ASYNC_DATABASE_URL
class Base(DeclarativeBase):
pass
# ─── Sync engine (SQLite fallback / admin) ──────────────────────────────────
is_sqlite = DATABASE_URL.startswith("sqlite")
sync_connect_args = {"check_same_thread": False} if is_sqlite else {}
engine = create_engine(DATABASE_URL, connect_args=sync_connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Включаем WAL mode для параллельных чтений и уменьшения SQLITE_BUSY if is_sqlite:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text("PRAGMA journal_mode=WAL")) conn.execute(text("PRAGMA journal_mode=WAL"))
conn.execute(text("PRAGMA synchronous=NORMAL")) conn.execute(text("PRAGMA synchronous=NORMAL"))
conn.execute(text("PRAGMA cache_size=-64000")) conn.execute(text("PRAGMA cache_size=-64000"))
conn.execute(text("PRAGMA busy_timeout=5000")) conn.execute(text("PRAGMA busy_timeout=5000"))
conn.commit() conn.commit()
# ─── Async engine (PostgreSQL production) ───────────────────────────────────
async_engine = create_async_engine(
ASYNC_DATABASE_URL,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
echo=False,
)
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)
# ─── Models ─────────────────────────────────────────────────────────────────
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
__table_args__ = (
Index("ix_users_username", "username"),
Index("ix_users_created_at", "created_at"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False) username = Column(String(50), unique=True, nullable=False)
hashed_password = Column(String(200), nullable=False) hashed_password = Column(String(200), nullable=False)
balance = Column(Float, default=0.0) balance = Column(Float, default=0.0)
@@ -29,11 +61,9 @@ class User(Base):
is_banned = Column(Boolean, default=False) is_banned = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
# Карта (кэш, источник правды — Bank API)
card_balance_cached = Column(Float, default=1000.0) card_balance_cached = Column(Float, default=1000.0)
total_deposited = Column(Float, default=0.0) # всего закинуто card→site total_deposited = Column(Float, default=0.0)
total_withdrawn = Column(Float, default=0.0) # всего выведено site→card total_withdrawn = Column(Float, default=0.0)
last_deposit_date = Column(DateTime, nullable=True) last_deposit_date = Column(DateTime, nullable=True)
inventory_items = relationship("InventoryItem", back_populates="user", cascade="all, delete-orphan") inventory_items = relationship("InventoryItem", back_populates="user", cascade="all, delete-orphan")
@@ -44,39 +74,32 @@ class User(Base):
upgrade_rpu = relationship("UpgradeRPU", back_populates="user", uselist=False, cascade="all, delete-orphan") upgrade_rpu = relationship("UpgradeRPU", back_populates="user", uselist=False, cascade="all, delete-orphan")
transactions = relationship("TransactionLog", back_populates="user", cascade="all, delete-orphan") transactions = relationship("TransactionLog", back_populates="user", cascade="all, delete-orphan")
class UpgradeRPU(Base): class UpgradeRPU(Base):
__tablename__ = "upgrade_rpu" __tablename__ = "upgrade_rpu"
__table_args__ = (Index("ix_upgrade_rpu_user_id", "user_id", unique=True),)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
# Множитель шанса апгрейда (1.0 = стандартный)
upgrade_multiplier = Column(Float, default=1.0) upgrade_multiplier = Column(Float, default=1.0)
# Автоматическая подкрутка
auto_adjust = Column(Boolean, default=False) auto_adjust = Column(Boolean, default=False)
# Статистика
total_attempts = Column(Integer, default=0) total_attempts = Column(Integer, default=0)
total_success = Column(Integer, default=0) total_success = Column(Integer, default=0)
total_spent_value = Column(Float, default=0.0) # Стоимость потерянных предметов total_spent_value = Column(Float, default=0.0)
# Серии
current_win_streak = Column(Integer, default=0) current_win_streak = Column(Integer, default=0)
current_lose_streak = Column(Integer, default=0) current_lose_streak = Column(Integer, default=0)
best_win_streak = Column(Integer, default=0) best_win_streak = Column(Integer, default=0)
worst_lose_streak = Column(Integer, default=0) worst_lose_streak = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="upgrade_rpu") user = relationship("User", back_populates="upgrade_rpu")
class Upgrade(Base): class Upgrade(Base):
__tablename__ = "upgrades" __tablename__ = "upgrades"
__table_args__ = (Index("ix_upgrades_user_id_created", "user_id", "created_at"),)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
input_item_id = Column(Integer, nullable=False) input_item_id = Column(Integer, nullable=False)
input_item_name = Column(String(200)) input_item_name = Column(String(200))
@@ -85,19 +108,18 @@ class Upgrade(Base):
target_item_name = Column(String(200)) target_item_name = Column(String(200))
target_item_image_url = Column(String(500), default="") target_item_image_url = Column(String(500), default="")
success = Column(Boolean, default=False) success = Column(Boolean, default=False)
probability = Column(Float) # Базовый шанс probability = Column(Float)
rpu_adjusted_probability = Column(Float) # Шанс с учетом РПУ rpu_adjusted_probability = Column(Float)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="upgrades") user = relationship("User", back_populates="upgrades")
class UserRPU(Base): class UserRPU(Base):
__tablename__ = "user_rpu" __tablename__ = "user_rpu"
__table_args__ = (Index("ix_user_rpu_user_id", "user_id", unique=True),)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
# Множители шансов для каждой редкости (1.0 = стандартный шанс)
consumer_multiplier = Column(Float, default=1.0) consumer_multiplier = Column(Float, default=1.0)
industrial_multiplier = Column(Float, default=1.0) industrial_multiplier = Column(Float, default=1.0)
mil_spec_multiplier = Column(Float, default=1.0) mil_spec_multiplier = Column(Float, default=1.0)
@@ -105,64 +127,44 @@ class UserRPU(Base):
classified_multiplier = Column(Float, default=1.0) classified_multiplier = Column(Float, default=1.0)
covert_multiplier = Column(Float, default=1.0) covert_multiplier = Column(Float, default=1.0)
rare_special_multiplier = Column(Float, default=1.0) rare_special_multiplier = Column(Float, default=1.0)
# Общий множитель удачи
luck_multiplier = Column(Float, default=1.0) luck_multiplier = Column(Float, default=1.0)
# Автоматическая подкрутка
auto_adjust = Column(Boolean, default=False) auto_adjust = Column(Boolean, default=False)
# Статистика для авто-подкрутки
total_spent = Column(Float, default=0.0) total_spent = Column(Float, default=0.0)
total_opened = Column(Integer, default=0) total_opened = Column(Integer, default=0)
total_value_received = Column(Float, default=0.0) total_value_received = Column(Float, default=0.0)
last_adjustment = Column(DateTime, nullable=True) last_adjustment = Column(DateTime, nullable=True)
# Серии
current_streak = Column(Integer, default=0) current_streak = Column(Integer, default=0)
best_streak = Column(Integer, default=0) best_streak = Column(Integer, default=0)
worst_streak = Column(Integer, default=0) worst_streak = Column(Integer, default=0)
# Последние результаты открытий
last_results = Column(String(500), default="") last_results = Column(String(500), default="")
luck_budget = Column(Float, default=100.0)
# ── RPU v2: Session (сессии) ── session_spent = Column(Float, default=0.0)
luck_budget = Column(Float, default=100.0) # 0-100, остаток удачи на сессию session_won = Column(Float, default=0.0)
session_spent = Column(Float, default=0.0) # потрачено в текущей сессии session_reset_date = Column(DateTime, nullable=True)
session_won = Column(Float, default=0.0) # выиграно (ценность) в сессии ceiling_multiplier = Column(Float, default=1.0)
session_reset_date = Column(DateTime, nullable=True) # когда сброшена сессия ceiling_break_count = Column(Integer, default=0)
ceiling_break_session = Column(Integer, default=0)
# ── RPU v2: Ceiling (потолок) ── consecutive_loss_value = Column(Float, default=0.0)
ceiling_multiplier = Column(Float, default=1.0) # временный множитель потолка (0.5-3.0)
ceiling_break_count = Column(Integer, default=0) # сколько раз пробивал потолок
ceiling_break_session = Column(Integer, default=0) # пробитий за сессию
# ── RPU v2: Comeback (комбек) ──
consecutive_loss_value = Column(Float, default=0.0) # стоимость последовательных проигрышей
consecutive_loss_count = Column(Integer, default=0) consecutive_loss_count = Column(Integer, default=0)
comeback_active = Column(Boolean, default=False) comeback_active = Column(Boolean, default=False)
comeback_openings_left = Column(Integer, default=0) comeback_openings_left = Column(Integer, default=0)
comeback_multiplier = Column(Float, default=1.0) # множитель удачи в режиме комбека comeback_multiplier = Column(Float, default=1.0)
hot_score = Column(Float, default=0.0)
# ── RPU v2: Hot/Cold анализ ──
hot_score = Column(Float, default=0.0) # >0 = горячий, <0 = холодный
last_activity_date = Column(DateTime, nullable=True) last_activity_date = Column(DateTime, nullable=True)
# Мета-данные
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="rpu_settings") user = relationship("User", back_populates="rpu_settings")
class PromoCode(Base): class PromoCode(Base):
__tablename__ = "promo_codes" __tablename__ = "promo_codes"
__table_args__ = (Index("ix_promo_codes_code", "code"), Index("ix_promo_codes_active", "is_active"))
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
code = Column(String(50), unique=True, nullable=False, index=True) code = Column(String(50), unique=True, nullable=False)
reward_type = Column(String(30), nullable=False) # card_to_site, luck_boost, ceiling_boost, free_case, reset_streak reward_type = Column(String(30), nullable=False)
reward_amount = Column(Float, default=0.0) reward_amount = Column(Float, default=0.0)
reward_data = Column(String(500), default="") # доп. параметры (JSON) reward_data = Column(String(500), default="")
max_uses = Column(Integer, default=1) max_uses = Column(Integer, default=1)
used_count = Column(Integer, default=0) used_count = Column(Integer, default=0)
start_date = Column(DateTime, default=datetime.utcnow) start_date = Column(DateTime, default=datetime.utcnow)
@@ -174,44 +176,57 @@ class PromoCode(Base):
class TransactionLog(Base): class TransactionLog(Base):
__tablename__ = "transaction_log" __tablename__ = "transaction_log"
__table_args__ = (
Index("ix_txlog_user_id", "user_id"),
Index("ix_txlog_created", "created_at"),
Index("ix_txlog_user_created", "user_id", "created_at"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
tx_type = Column(String(30), nullable=False) # card_to_site, site_to_card, promo_bonus, admin tx_type = Column(String(30), nullable=False)
amount = Column(Float, default=0.0) amount = Column(Float, default=0.0)
fee = Column(Float, default=0.0) fee = Column(Float, default=0.0)
promo_code = Column(String(50), nullable=True) promo_code = Column(String(50), nullable=True)
item_name = Column(String(200), nullable=True) # при выводе скина item_name = Column(String(200), nullable=True)
inventory_item_id = Column(Integer, nullable=True) inventory_item_id = Column(Integer, nullable=True)
details = Column(String(500), default="") details = Column(String(500), default="")
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="transactions") user = relationship("User", back_populates="transactions")
class InventoryItem(Base): class InventoryItem(Base):
__tablename__ = "inventory_items" __tablename__ = "inventory_items"
__table_args__ = (
Index("ix_inventory_user_id", "user_id"),
Index("ix_inventory_user_obtained", "user_id", "obtained_at"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
item_id = Column(Integer, nullable=False) # ID из ALL_ITEMS item_id = Column(Integer, nullable=False)
market_hash_name = Column(String(200), nullable=False) market_hash_name = Column(String(200), nullable=False)
rarity = Column(String(50)) rarity = Column(String(50))
wear = Column(String(50)) wear = Column(String(50))
float_value = Column(Float, default=0.0) float_value = Column(Float, default=0.0)
type = Column(String(20), default="Normal") type = Column(String(20), default="Normal")
obtained_from = Column(String(100)) # "case: название" или "contract" obtained_from = Column(String(100))
obtained_at = Column(DateTime, default=datetime.utcnow) obtained_at = Column(DateTime, default=datetime.utcnow)
is_equipped = Column(Boolean, default=False) is_equipped = Column(Boolean, default=False)
image_url = Column(String(500), default="") image_url = Column(String(500), default="")
price_rub = Column(Float, default=0.0) price_rub = Column(Float, default=0.0)
user = relationship("User", back_populates="inventory_items") user = relationship("User", back_populates="inventory_items")
class CaseOpening(Base): class CaseOpening(Base):
__tablename__ = "case_openings" __tablename__ = "case_openings"
__table_args__ = (
Index("ix_case_openings_user_id", "user_id"),
Index("ix_case_openings_opened_at", "opened_at"),
Index("ix_case_openings_user_opened", "user_id", "opened_at"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
case_name = Column(String(100), nullable=False) case_name = Column(String(100), nullable=False)
item_id = Column(Integer, nullable=False) item_id = Column(Integer, nullable=False)
@@ -219,197 +234,138 @@ class CaseOpening(Base):
rarity = Column(String(50)) rarity = Column(String(50))
float_value = Column(Float) float_value = Column(Float)
opened_at = Column(DateTime, default=datetime.utcnow) opened_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="case_openings") user = relationship("User", back_populates="case_openings")
class Contract(Base): class Contract(Base):
__tablename__ = "contracts" __tablename__ = "contracts"
__table_args__ = (
Index("ix_contracts_user_id", "user_id"),
Index("ix_contracts_user_created", "user_id", "created_at"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
input_item_ids = Column(Text, nullable=False) # JSON список ID из инвентаря input_item_ids = Column(Text, nullable=False)
output_item_id = Column(Integer, nullable=False) output_item_id = Column(Integer, nullable=False)
output_item_name = Column(String(200)) output_item_name = Column(String(200))
output_float = Column(Float) output_float = Column(Float)
probability = Column(Float) probability = Column(Float)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="contracts") user = relationship("User", back_populates="contracts")
class Achievement(Base): class Achievement(Base):
__tablename__ = "achievements" __tablename__ = "achievements"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
name = Column(String(100), unique=True, nullable=False) name = Column(String(100), unique=True, nullable=False)
title = Column(String(200), nullable=False) title = Column(String(200), nullable=False)
description = Column(String(500)) description = Column(String(500))
icon = Column(String(50), default="🏆") icon = Column(String(50), default="🏆")
category = Column(String(50), default="general") # cases, contracts, upgrade, social, general category = Column(String(50), default="general")
requirement_type = Column(String(50), nullable=False) # open_cases, contracts, upgrade_wins, total_spent, valuable_item, etc. requirement_type = Column(String(50), nullable=False)
requirement_value = Column(Integer, nullable=False) # how many requirement_value = Column(Integer, nullable=False)
reward_amount = Column(Float, default=0.0) # bonus rubles reward_amount = Column(Float, default=0.0)
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
hidden = Column(Boolean, default=False) # секретные достижения hidden = Column(Boolean, default=False)
user_achievements = relationship("UserAchievement", back_populates="achievement") user_achievements = relationship("UserAchievement", back_populates="achievement")
class UserAchievement(Base): class UserAchievement(Base):
__tablename__ = "user_achievements" __tablename__ = "user_achievements"
__table_args__ = (
Index("ix_user_achievements_user", "user_id"),
Index("ix_user_achievements_ach", "achievement_id"),
Index("ix_user_achievements_both", "user_id", "achievement_id", unique=True),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False) achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False)
progress = Column(Integer, default=0) # текущий прогресс progress = Column(Integer, default=0)
unlocked_at = Column(DateTime, nullable=True) unlocked_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", backref="user_achievements") user = relationship("User", backref="user_achievements")
achievement = relationship("Achievement", back_populates="user_achievements") achievement = relationship("Achievement", back_populates="user_achievements")
class ActivityFeed(Base): class ActivityFeed(Base):
__tablename__ = "activity_feed" __tablename__ = "activity_feed"
__table_args__ = (
Index("ix_activity_user_id", "user_id"),
Index("ix_activity_created", "created_at"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
username = Column(String(50), nullable=False) username = Column(String(50), nullable=False)
activity_type = Column(String(50), nullable=False) # case_open, contract, upgrade, achievement, trade, crash activity_type = Column(String(50), nullable=False)
message = Column(String(500), nullable=False) message = Column(String(500), nullable=False)
data_json = Column(Text, default="{}") # дополнительные данные (item_name, price, etc.) data_json = Column(Text, default="{}")
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", backref="activities") user = relationship("User", backref="activities")
Base.metadata.create_all(bind=engine)
# Миграция: добавляем недостающие индексы для производительности # ─── Create tables ──────────────────────────────────────────────────────────
try: def init_sync_db():
from sqlalchemy import inspect Base.metadata.create_all(bind=engine)
inspector = inspect(engine) _run_migrations(engine, is_sync=True)
indices_to_add = {
"inventory_items": ["user_id"],
"case_openings": ["user_id", "opened_at"],
"upgrades": ["user_id"],
"contracts": ["user_id"],
"activity_feed": ["user_id"],
"user_achievements": ["user_id", "achievement_id"],
}
with engine.connect() as conn:
for tbl, cols in indices_to_add.items():
existing = [ix["name"] for ix in inspector.get_indexes(tbl)]
for col in cols:
ix_name = f"ix_{tbl}_{col}"
if ix_name not in existing:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {ix_name} ON {tbl} ({col})"))
conn.commit()
print("[DB] Индексы: OK")
except Exception as e:
print(f"[DB] Миграция индексов: {e}")
# Миграция: добавляем колонку hidden если её нет
try:
from sqlalchemy import inspect
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("achievements")]
if "hidden" not in cols:
from sqlalchemy import text
with engine.connect() as conn:
conn.execute(text("ALTER TABLE achievements ADD COLUMN hidden BOOLEAN DEFAULT 0"))
conn.commit()
except Exception as e:
print(f"[DB] Миграция hidden: {e}")
# Миграция: добавляем image_url и price_rub в inventory_items async def init_async_db():
try: async with async_engine.begin() as conn:
from sqlalchemy import text await conn.run_sync(Base.metadata.create_all)
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("inventory_items")]
if "image_url" not in cols:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE inventory_items ADD COLUMN image_url VARCHAR(500) DEFAULT ''"))
conn.execute(text("ALTER TABLE inventory_items ADD COLUMN price_rub FLOAT DEFAULT 0.0"))
conn.commit()
# Backfill existing rows from ALL_ITEMS
try:
from backend import ALL_ITEMS
with engine.connect() as conn:
rows = conn.execute(text("SELECT id, item_id FROM inventory_items WHERE image_url = '' OR price_rub = 0.0")).fetchall()
for row_id, item_id in rows:
if 0 <= item_id < len(ALL_ITEMS):
item = ALL_ITEMS[item_id]
img = item.get("image_url", "")
pr = item.get("price_rub", 0.0)
conn.execute(
text("UPDATE inventory_items SET image_url = :img, price_rub = :pr WHERE id = :rid"),
{"img": img, "pr": pr, "rid": row_id}
)
conn.commit()
print(f"[DB] Backfilled {len(rows)} inventory_items from ALL_ITEMS")
except Exception as e2:
print(f"[DB] Backfill inventory_items: {e2}")
except Exception as e:
print(f"[DB] Миграция inventory_items: {e}")
# Миграция: новые поля в users (карта)
try:
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("users")]
if "card_balance_cached" not in cols:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE users ADD COLUMN card_balance_cached FLOAT DEFAULT 1000.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN total_deposited FLOAT DEFAULT 0.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN total_withdrawn FLOAT DEFAULT 0.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN last_deposit_date TIMESTAMP"))
conn.commit()
print("[DB] Миграция users (card): OK")
except Exception as e:
print(f"[DB] Миграция users card: {e}")
# Миграция: новые поля в user_rpu (v2) def _run_migrations(engine_or_conn, is_sync: bool):
try: try:
inspector = inspect(engine) from sqlalchemy import inspect as sa_inspect
cols = [c["name"] for c in inspector.get_columns("user_rpu")] inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn)
for col, coltype in [ existing_tables = inspector.get_table_names()
("luck_budget", "FLOAT DEFAULT 100.0"), for tbl_name in Base.metadata.tables:
("session_spent", "FLOAT DEFAULT 0.0"), if tbl_name not in existing_tables:
("session_won", "FLOAT DEFAULT 0.0"), continue
("session_reset_date", "TIMESTAMP"), model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns}
("ceiling_multiplier", "FLOAT DEFAULT 1.0"), db_cols = {c["name"] for c in inspector.get_columns(tbl_name)}
("ceiling_break_count", "INTEGER DEFAULT 0"), missing = model_cols - db_cols
("ceiling_break_session", "INTEGER DEFAULT 0"), if missing:
("consecutive_loss_value", "FLOAT DEFAULT 0.0"), print(f"[DB] Migrating {tbl_name}: adding {missing}")
("consecutive_loss_count", "INTEGER DEFAULT 0"), except Exception as e:
("comeback_active", "BOOLEAN DEFAULT 0"), print(f"[DB] Migration check: {e}")
("comeback_openings_left", "INTEGER DEFAULT 0"),
("comeback_multiplier", "FLOAT DEFAULT 1.0"),
("hot_score", "FLOAT DEFAULT 0.0"),
("last_activity_date", "TIMESTAMP"),
]:
if col not in cols:
with engine.connect() as conn:
conn.execute(text(f"ALTER TABLE user_rpu ADD COLUMN {col} {coltype}"))
conn.commit()
print(f"[DB] Миграция user_rpu: {col}")
except Exception as e:
print(f"[DB] Миграция user_rpu: {e}")
# Миграция: создаём PromoCode если нет
try:
Base.metadata.tables["promo_codes"].create(bind=engine, checkfirst=True)
except:
pass
# Миграция: создаём TransactionLog если нет # ─── Session helpers ────────────────────────────────────────────────────────
try: def get_db() -> Generator:
Base.metadata.tables["transaction_log"].create(bind=engine, checkfirst=True)
except:
pass
def get_db():
db = SessionLocal() db = SessionLocal()
try: try:
yield db yield db
finally: finally:
db.close() db.close()
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as db:
try:
yield db
finally:
await db.close()
# ─── Thread-safe sync session wrapper for async endpoints ──────────────────
# Use this as a bridge: runs sync DB ops in executor to avoid blocking event loop.
# The session is closed when the context exits.
import asyncio
from contextlib import asynccontextmanager
@asynccontextmanager
async def get_db_executor():
"""Async context manager wrapping sync SessionLocal in executor thread.
Use for endpoints that haven't been fully async-converted yet."""
loop = asyncio.get_running_loop()
db = await loop.run_in_executor(None, lambda: SessionLocal())
try:
yield db
finally:
await loop.run_in_executor(None, db.close)
+77 -19
View File
@@ -29,30 +29,29 @@ from pathlib import Path
import json import json
from pathlib import Path from pathlib import Path
from database import get_db, SessionLocal, User, InventoryItem, CaseOpening, Contract, Upgrade, UpgradeRPU, PromoCode from database import get_db, get_db_executor, get_async_db, AsyncSessionLocal, SessionLocal, User, InventoryItem, CaseOpening, Contract, Upgrade, UpgradeRPU, PromoCode
from websocket_manager import manager, crash_game from websocket_manager import manager, crash_game
from sqlalchemy import select, func as sa_func, desc
from achievements import ( from achievements import (
seed_achievements, check_achievement, seed_achievements, check_achievement,
check_open_cases_achievements, check_contracts_achievements, check_open_cases_achievements, check_contracts_achievements,
check_upgrade_achievements, check_upgrade_risk_achievements, check_upgrade_result_achievements, check_upgrade_achievements, check_upgrade_risk_achievements,
check_rare_item_achievement, check_knife_achievement, check_upgrade_result_achievements, check_rare_item_achievement,
check_spent_achievements, check_knife_achievement, check_spent_achievements,
check_balance_achievements, check_balance_achievements, check_slot_match_achievements,
check_slot_match_achievements, check_slot_plays_achievements, check_slot_plays_achievements, check_slot_match_count_achievements,
check_slot_match_count_achievements, check_inventory_achievements, check_dildo_achievements,
check_inventory_achievements, check_dildo_contract_achievement, check_crash_achievements,
check_dildo_achievements, check_dildo_contract_achievement, check_crash_cashout_achievements, check_items_unboxed_achievements,
check_crash_achievements, check_crash_cashout_achievements, check_total_profit_achievements, check_achievements_count_achievements,
check_items_unboxed_achievements, check_total_profit_achievements,
check_achievements_count_achievements,
check_rare_item_count_achievements, check_knife_count_achievements, check_rare_item_count_achievements, check_knife_count_achievements,
check_midnight_case_achievement, check_exact_cases_achievement, check_midnight_case_achievement, check_exact_cases_achievement,
check_collection_achievements, check_collection_achievements, get_user_achievements
get_user_achievements
) )
from auth import ( from auth import (
create_user, authenticate_user, create_access_token, create_user, authenticate_user, create_access_token,
get_current_user, get_current_user_optional, get_password_hash, get_current_user, get_current_user_optional, get_password_hash,
get_current_user_sync, get_current_user_optional_sync,
ACCESS_TOKEN_EXPIRE_MINUTES, is_item_craftable ACCESS_TOKEN_EXPIRE_MINUTES, is_item_craftable
) )
@@ -70,9 +69,10 @@ from backend import (
from upgrade import ( from upgrade import (
get_upgrade_rpu, calculate_upgrade_probability, get_upgrade_rpu, calculate_upgrade_probability,
get_adjusted_upgrade_probability, spin_upgrade_wheel, get_adjusted_upgrade_probability, spin_upgrade_wheel,
update_upgrade_stats update_upgrade_stats,
get_upgrade_rpu_async, get_adjusted_upgrade_probability_async,
update_upgrade_stats_async
) )
from rpu import get_adjusted_weights, get_user_rpu, update_rpu_stats, RARITY_TO_FIELD from rpu import get_adjusted_weights, get_user_rpu, update_rpu_stats, RARITY_TO_FIELD
from auth import get_password_hash from auth import get_password_hash
@@ -100,6 +100,40 @@ from backend import app as api_app
# Монтируем оригинальное API на /api/simulator чтобы избежать конфликтов # Монтируем оригинальное API на /api/simulator чтобы избежать конфликтов
app.mount("/api/simulator", api_app) app.mount("/api/simulator", api_app)
# ─── Pagination helper ─────────────────────────────────────────────────────
DEFAULT_PAGE_SIZE = 50
def paginate(query, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE):
offset = (page - 1) * page_size
return query.offset(offset).limit(page_size)
def paginated_response(items, total: int, page: int, page_size: int = DEFAULT_PAGE_SIZE):
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": max(1, (total + page_size - 1) // page_size),
}
# ─── Rate Limiting ──────────────────────────────────────────────────────────
from collections import defaultdict
import time
_rate_limit_store: dict = defaultdict(list)
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX_CASES = 30 # max case opens per window
RATE_LIMIT_MAX_GENERAL = 120 # max general requests per window
async def rate_limit_check(user_id: int, action: str, max_req: int, window: int = RATE_LIMIT_WINDOW):
key = f"{user_id}:{action}"
now = time.time()
_rate_limit_store[key] = [t for t in _rate_limit_store.get(key, []) if now - t < window]
if len(_rate_limit_store[key]) >= max_req:
return False
_rate_limit_store[key].append(now)
return True
# ─── Вспомогательные функции ─────────────────────────────────────────────── # ─── Вспомогательные функции ───────────────────────────────────────────────
# Кэш dildo-предметов (создаётся один раз) # Кэш dildo-предметов (создаётся один раз)
@@ -243,9 +277,33 @@ def record_activity_own_session(user_id: int, username: str, activity_type: str,
} }
# Сидируем достижения при старте # Сидируем достижения при старте
db_seed = next(get_db()) try:
seed_achievements(db_seed) from database import init_sync_db
db_seed.close() init_sync_db()
db_seed = next(get_db())
from achievements import ACHIEVEMENT_DEFS
from database import Achievement
existing = db_seed.query(Achievement).count()
if existing == 0:
for defn in ACHIEVEMENT_DEFS:
db_seed.add(Achievement(**defn))
db_seed.commit()
print(f"[Seed] Добавлено {len(ACHIEVEMENT_DEFS)} достижений")
else:
existing_names = {a.name for a in db_seed.query(Achievement.name).all()}
added = 0
for defn in ACHIEVEMENT_DEFS:
if defn["name"] not in existing_names:
db_seed.add(Achievement(**defn))
added += 1
if added:
db_seed.commit()
print(f"[Seed] Добавлено {added} новых достижений")
db_seed.close()
except Exception as e:
print(f"[Seed] {e}")
import traceback
traceback.print_exc()
async def broadcast_online_count(): async def broadcast_online_count():
"""Рассылает всем количество игроков онлайн""" """Рассылает всем количество игроков онлайн"""
+85
View File
@@ -0,0 +1,85 @@
"""
Миграция данных из SQLite в PostgreSQL.
Запуск: python migrate_db.py
Требует настроенного PostgreSQL (см. database.py)
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from database import DATABASE_URL, ASYNC_DATABASE_URL, Base, User, InventoryItem, CaseOpening, Contract
from database import Achievement, UserAchievement, ActivityFeed, UserRPU, UpgradeRPU, Upgrade, PromoCode, TransactionLog
# Sync SQLite source
SOURCE_URL = DATABASE_URL
# Sync PostgreSQL target (derive from async URL)
TARGET_URL = ASYNC_DATABASE_URL.replace("+asyncpg", "").replace("+aiosqlite", "")
if "sqlite" in SOURCE_URL:
print(f"Source: {SOURCE_URL}")
print(f"Target: {TARGET_URL}")
print("---")
else:
print("Source already PostgreSQL — nothing to migrate.")
sys.exit(0)
source_engine = create_engine(SOURCE_URL)
target_engine = create_engine(TARGET_URL, pool_pre_ping=True)
SourceSession = sessionmaker(bind=source_engine)
TargetSession = sessionmaker(bind=target_engine)
def migrate_table(model, target_session):
"""Copies all rows from source model to target model"""
source_session = SourceSession()
try:
rows = source_session.query(model).all()
if not rows:
return 0
count = 0
for row in rows:
# Detach from source session
source_session.expunge(row)
target_session.merge(row)
count += 1
target_session.commit()
return count
except Exception as e:
target_session.rollback()
print(f" ERROR: {e}")
return 0
finally:
source_session.close()
def main():
print("Creating target tables...")
Base.metadata.create_all(bind=target_engine)
target_session = TargetSession()
models = [
(User, "users"),
(UserRPU, "user_rpu"),
(UpgradeRPU, "upgrade_rpu"),
(InventoryItem, "inventory_items"),
(CaseOpening, "case_openings"),
(Contract, "contracts"),
(Achievement, "achievements"),
(UserAchievement, "user_achievements"),
(ActivityFeed, "activity_feed"),
(Upgrade, "upgrades"),
(PromoCode, "promo_codes"),
(TransactionLog, "transaction_log"),
]
for model, name in models:
print(f"Migrating {name}... ", end="", flush=True)
count = migrate_table(model, target_session)
print(f"{count} rows")
target_session.close()
print("\nMigration complete! Set ASYNC_DATABASE_URL to the PostgreSQL URL to use it.")
if __name__ == "__main__":
main()
+3 -1
View File
@@ -6,9 +6,11 @@ markupsafe>=3.0.0
sqlalchemy>=2.0 sqlalchemy>=2.0
python-jose[cryptography] python-jose[cryptography]
passlib passlib
bcrypt bcrypt>=4.0.0
python-multipart python-multipart
python-dateutil python-dateutil
aiofiles aiofiles
tqdm tqdm
aiosqlite aiosqlite
asyncpg
redis>=5.0
+240 -414
View File
@@ -3,21 +3,16 @@ import random
import math import math
from datetime import datetime, date from datetime import datetime, date
from typing import Dict, Optional, Tuple 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 database import User, UserRPU, InventoryItem
from promo_phase import get_promo_effect_for_user, get_current_phase from promo_phase import get_promo_effect_for_user, get_current_phase
# Стандартные веса редкостей
BASE_RARITY_WEIGHTS = { BASE_RARITY_WEIGHTS = {
"Consumer Grade": 10000, "Consumer Grade": 10000, "Industrial Grade": 5000,
"Industrial Grade": 5000, "Mil-Spec": 2500, "Mil-Spec Grade": 2500,
"Mil-Spec": 2500, "Restricted": 625, "Classified": 125,
"Mil-Spec Grade": 2500, "Covert": 25, "Rare Special Item": 5, "Extraordinary": 5
"Restricted": 625,
"Classified": 125,
"Covert": 25,
"Rare Special Item": 5,
"Extraordinary": 5
} }
RARITY_TO_FIELD = { RARITY_TO_FIELD = {
@@ -33,421 +28,321 @@ RARITY_TO_FIELD = {
} }
RARITY_RANK = { RARITY_RANK = {
"Consumer Grade": 0, "Consumer Grade": 0, "Industrial Grade": 1,
"Industrial Grade": 1, "Mil-Spec": 2, "Mil-Spec Grade": 2,
"Mil-Spec": 2, "Restricted": 3, "Classified": 4, "Covert": 5,
"Mil-Spec Grade": 2, "Rare Special Item": 6, "Extraordinary": 6
"Restricted": 3,
"Classified": 4,
"Covert": 5,
"Rare Special Item": 6,
"Extraordinary": 6
} }
# Пороги потолка
CEILING_BASE_MULTIPLIER = 3.0 CEILING_BASE_MULTIPLIER = 3.0
CEILING_MIN = 1000.0 CEILING_MIN = 1000.0
CEILING_SESSION_HISTORY = { CEILING_SESSION_HISTORY = {0: 5.0, 1: 4.0, 2: 3.5, -1: 3.0}
0: 5.0, # первая сессия
1: 4.0, # вторая
2: 3.5, # третья
-1: 3.0 # 3+ сессий
}
# Пороги для luck_budget
BUDGET_MAX = 100.0 BUDGET_MAX = 100.0
BUDGET_CONSUMPTION_RATE = 0.001 # 0.1% от цены предмета BUDGET_CONSUMPTION_RATE = 0.001
BUDGET_REGEN_PER_OPEN = 0.5 # +0.5% за каждое открытие BUDGET_REGEN_PER_OPEN = 0.5
BUDGET_LOW_THRESHOLD = 20.0 # если budget < 20%, удача падает BUDGET_LOW_THRESHOLD = 20.0
# Комбек COMEBACK_TRIGGER_COUNT = 5
COMEBACK_TRIGGER_COUNT = 5 # 5+ проигрышей подряд → комбек COMEBACK_TRIGGER_VALUE_RATIO = 0.3
COMEBACK_TRIGGER_VALUE_RATIO = 0.3 # потеря >30% от total_deposited COMEBACK_OPENINGS = 5
COMEBACK_OPENINGS = 5 # комбек на 5 открытий COMEBACK_MULTIPLIER_MAX = 3.0
COMEBACK_MULTIPLIER_MAX = 3.0 # макс множитель удачи в комбеке
# Hot/Cold HOT_THRESHOLD_SPENT = 10000
HOT_THRESHOLD_SPENT = 10000 # потратил >10k HOT_THRESHOLD_WITHDRAWAL = 0.1
HOT_THRESHOLD_WITHDRAWAL = 0.1 # вывел <10% от депозитов COLD_DAYS_INACTIVE = 7
COLD_DAYS_INACTIVE = 7 # 7 дней неактивен = холодный COLD_SPENT_PER_DAY = 100
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.31.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 _BATCH_MODE = False
# Кэш adjusted_weights на время batch-операции
# Ключ: user_id, значение: результат get_adjusted_weights
_BATCH_WEIGHTS_CACHE = {} _BATCH_WEIGHTS_CACHE = {}
def _rpu_commit_or_flush(db): def _rpu_commit_or_flush(db):
if _BATCH_MODE: if _BATCH_MODE:
db.flush() db.flush()
else: else:
db.commit() 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): def _batch_cache_get(user_id: int):
"""Возвращает кэшированные adjusted_weights для batch или None."""
if _BATCH_MODE and user_id in _BATCH_WEIGHTS_CACHE: if _BATCH_MODE and user_id in _BATCH_WEIGHTS_CACHE:
return _BATCH_WEIGHTS_CACHE[user_id] return _BATCH_WEIGHTS_CACHE[user_id]
return None return None
def _batch_cache_set(user_id: int, weights: dict): def _batch_cache_set(user_id: int, weights: dict):
"""Сохраняет adjusted_weights в кэш batch."""
if _BATCH_MODE: if _BATCH_MODE:
_BATCH_WEIGHTS_CACHE[user_id] = weights _BATCH_WEIGHTS_CACHE[user_id] = weights
def _batch_cache_clear(user_id: int = None): def _batch_cache_clear(user_id: int = None):
"""Очищает кэш batch (по user_id или полностью)."""
if user_id is not None: if user_id is not None:
_BATCH_WEIGHTS_CACHE.pop(user_id, None) _BATCH_WEIGHTS_CACHE.pop(user_id, None)
else: else:
_BATCH_WEIGHTS_CACHE.clear() _BATCH_WEIGHTS_CACHE.clear()
def consume_luck_budget(db: Session, user_id: int, item_price: float):
rpu = get_user_rpu(db, user_id) async def get_user_rpu(db: AsyncSession, user_id: int) -> UserRPU:
consumption = item_price * BUDGET_CONSUMPTION_RATE result = await db.execute(select(UserRPU).where(UserRPU.user_id == user_id))
rpu.luck_budget = max(0.0, rpu.luck_budget - consumption) rpu = result.scalar_one_or_none()
_rpu_commit_or_flush(db) 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): async def check_and_reset_session(db: AsyncSession, user_id: int):
rpu = get_user_rpu(db, user_id) 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.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: def get_budget_factor(rpu: UserRPU) -> float:
"""
Возвращает множитель на основе luck_budget.
Если budget низкий (<20%) удача падает.
"""
if rpu.luck_budget >= BUDGET_LOW_THRESHOLD: if rpu.luck_budget >= BUDGET_LOW_THRESHOLD:
return 1.0 return 1.0
return max(0.3, rpu.luck_budget / BUDGET_LOW_THRESHOLD) return max(0.3, rpu.luck_budget / BUDGET_LOW_THRESHOLD)
# ── Comeback System ── async def update_comeback_tracking(db: AsyncSession, user_id: int, spent: float, received_value: float):
rpu = await get_user_rpu(db, user_id)
def update_comeback_tracking(db: Session, user_id: int, spent: float, result = await db.execute(select(User).where(User.id == user_id))
received_value: float): user = result.scalar_one_or_none()
"""
Отслеживает проигрыши для триггера комбека.
"""
rpu = get_user_rpu(db, user_id)
user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
return return
# Если проиграл (получил меньше чем потратил)
if received_value < spent * 0.8: if received_value < spent * 0.8:
rpu.consecutive_loss_count += 1 rpu.consecutive_loss_count += 1
rpu.consecutive_loss_value += (spent - received_value) rpu.consecutive_loss_value += (spent - received_value)
else: else:
rpu.consecutive_loss_count = 0 rpu.consecutive_loss_count = 0
rpu.consecutive_loss_value = 0.0 rpu.consecutive_loss_value = 0.0
# Проверяем триггер комбека
total_dep = max(1.0, user.total_deposited) total_dep = max(1.0, user.total_deposited)
loss_ratio = rpu.consecutive_loss_value / total_dep loss_ratio = rpu.consecutive_loss_value / total_dep
if (rpu.consecutive_loss_count >= COMEBACK_TRIGGER_COUNT or if (rpu.consecutive_loss_count >= COMEBACK_TRIGGER_COUNT or
loss_ratio >= COMEBACK_TRIGGER_VALUE_RATIO): loss_ratio >= COMEBACK_TRIGGER_VALUE_RATIO):
if not rpu.comeback_active: if not rpu.comeback_active:
rpu.comeback_active = True rpu.comeback_active = True
rpu.comeback_openings_left = COMEBACK_OPENINGS rpu.comeback_openings_left = COMEBACK_OPENINGS
rpu.comeback_multiplier = COMEBACK_MULTIPLIER_MAX rpu.comeback_multiplier = COMEBACK_MULTIPLIER_MAX
await _rpu_commit_or_flush_async(db)
_rpu_commit_or_flush(db)
def apply_comeback(db: Session, user_id: int) -> float: async def apply_comeback(db: AsyncSession, user_id: int) -> float:
""" rpu = await get_user_rpu(db, user_id)
Применяет комбек-множитель если активен.
Возвращает множитель для следующего открытия.
"""
rpu = get_user_rpu(db, user_id)
if not rpu.comeback_active or rpu.comeback_openings_left <= 0: if not rpu.comeback_active or rpu.comeback_openings_left <= 0:
return 1.0 return 1.0
mult = rpu.comeback_multiplier mult = rpu.comeback_multiplier
rpu.comeback_openings_left -= 1 rpu.comeback_openings_left -= 1
if rpu.comeback_openings_left <= 0: if rpu.comeback_openings_left <= 0:
rpu.comeback_active = False rpu.comeback_active = False
rpu.comeback_multiplier = 1.0 rpu.comeback_multiplier = 1.0
rpu.consecutive_loss_count = 0 rpu.consecutive_loss_count = 0
rpu.consecutive_loss_value = 0.0 rpu.consecutive_loss_value = 0.0
await _rpu_commit_or_flush_async(db)
_rpu_commit_or_flush(db)
return mult return mult
# ── Hot/Cold Analysis ── 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))
def analyze_hot_cold(db: Session, user_id: int) -> Tuple[bool, bool, float]: user = result.scalar_one_or_none()
"""
Анализирует пользователя: горячий / холодный.
Возвращает (is_hot, is_cold, withdrawal_ratio).
"""
user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
return False, True, 0.0 return False, True, 0.0
total_dep = max(0.0, user.total_deposited) total_dep = max(0.0, user.total_deposited)
total_wd = max(0.0, user.total_withdrawn) total_wd = max(0.0, user.total_withdrawn)
wd_ratio = total_wd / max(1.0, total_dep) wd_ratio = total_wd / max(1.0, total_dep)
# Горячий: много потратил, почти не выводил
is_hot = (total_dep >= HOT_THRESHOLD_SPENT and wd_ratio < HOT_THRESHOLD_WITHDRAWAL) is_hot = (total_dep >= HOT_THRESHOLD_SPENT and wd_ratio < HOT_THRESHOLD_WITHDRAWAL)
rpu = await get_user_rpu(db, user_id)
# Холодный: давно не был или мало активность
rpu = get_user_rpu(db, user_id)
is_cold = False is_cold = False
if rpu.last_activity_date: if rpu.last_activity_date:
days_since = (datetime.utcnow() - rpu.last_activity_date).days days_since = (datetime.utcnow() - rpu.last_activity_date).days
if days_since >= COLD_DAYS_INACTIVE: if days_since >= COLD_DAYS_INACTIVE:
is_cold = True is_cold = True
else: else:
is_cold = True # Новичок без активности is_cold = True
# Считаем hot_score
days_active = max(1, (datetime.utcnow() - user.created_at).days) days_active = max(1, (datetime.utcnow() - user.created_at).days)
spent_per_day = total_dep / days_active spent_per_day = total_dep / days_active
if spent_per_day < COLD_SPENT_PER_DAY and total_dep > 0: if spent_per_day < COLD_SPENT_PER_DAY and total_dep > 0:
is_cold = True is_cold = True
hot_score = total_dep - (total_wd * 2) hot_score = total_dep - (total_wd * 2)
rpu.hot_score = hot_score rpu.hot_score = hot_score
rpu.last_activity_date = datetime.utcnow() 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 return is_hot, is_cold, wd_ratio
# ── Withdrawal Tax (Открутка при выводе) ── async def check_withdrawal_penalty(db: AsyncSession, user_id: int):
result = await db.execute(select(User).where(User.id == user_id))
def check_withdrawal_penalty(db: Session, user_id: int): user = result.scalar_one_or_none()
"""
Если пользователь вывел >50% от депозитов скручиваем удачу и потолок.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
return return
total_dep = max(1.0, user.total_deposited) total_dep = max(1.0, user.total_deposited)
total_wd = max(0.0, user.total_withdrawn) total_wd = max(0.0, user.total_withdrawn)
wd_ratio = total_wd / total_dep wd_ratio = total_wd / total_dep
if wd_ratio > 0.5: 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.luck_multiplier = max(0.3, rpu.luck_multiplier - wd_ratio * 0.3)
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7) 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 def get_rpu_description(level: float) -> str:
возвращают закешированное значение без DB-запросов. 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) cached = _batch_cache_get(user_id)
if cached is not None: if cached is not None:
return cached return cached
check_and_reset_session(db, user_id) await check_and_reset_session(db, user_id)
rpu = get_user_rpu(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) promo = get_promo_effect_for_user(user_id, rpu.hot_score, wd_ratio, is_cold)
comeback_mult = await apply_comeback(db, user_id)
# Комбек
comeback_mult = apply_comeback(db, user_id)
# Budget factor
budget_factor = get_budget_factor(rpu) budget_factor = get_budget_factor(rpu)
ceiling_factor = await get_ceiling_luck_factor(db, user_id)
# Ceiling factor — не блокирует, а лишь приглушает удачу
ceiling_factor = get_ceiling_luck_factor(db, user_id)
adjusted = {} adjusted = {}
for rarity, base_weight in BASE_RARITY_WEIGHTS.items(): 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 base_multiplier = getattr(rpu, field, 1.0) if field else 1.0
rarity_index = RARITY_RANK.get(rarity, 0) rarity_index = RARITY_RANK.get(rarity, 0)
if rpu_level < 50: if level < 50:
luck_factor = 1.0 + ((50 - rpu_level) / 50) * rarity_index * 0.3 luck_factor = 1.0 + ((50 - level) / 50) * rarity_index * 0.3
else: 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)) luck_factor = max(0.1, min(3.0, luck_factor))
# Применяем все множители
final = base_multiplier * rpu.luck_multiplier * luck_factor final = base_multiplier * rpu.luck_multiplier * luck_factor
# Промо-фаза
final *= (1.0 + promo["luck_modifier"]) final *= (1.0 + promo["luck_modifier"])
# Budget
final *= budget_factor final *= budget_factor
# Комбек
final *= comeback_mult final *= comeback_mult
# Потолок — не блокирует, а лишь приглушает удачу
final *= ceiling_factor final *= ceiling_factor
# Редкие предметы получают доп модификатор от промо
if rarity_index >= 3: if rarity_index >= 3:
final *= (1.0 + promo.get("rare_chance_modifier", 0)) 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 return adjusted
def calculate_rpu_level(rpu: UserRPU) -> float: async def auto_adjust_rpu(db: AsyncSession, user_id: int):
multipliers = [ rpu = await get_user_rpu(db, user_id)
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: if not rpu.auto_adjust:
return return
result = await db.execute(select(User).where(User.id == user_id))
user = db.query(User).filter(User.id == user_id).first() user = result.scalar_one_or_none()
if not user: if not user:
return return
if rpu.total_opened > 0 and rpu.total_spent > 0: if rpu.total_opened > 0 and rpu.total_spent > 0:
roi = rpu.total_value_received / rpu.total_spent roi = rpu.total_value_received / rpu.total_spent
if roi < 0.4: if roi < 0.4:
boost = (0.4 - roi) * 2 boost = (0.4 - roi) * 2
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost) rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
v = getattr(rpu, r) setattr(rpu, r, min(2.0, getattr(rpu, r) + boost * 0.3))
setattr(rpu, r, min(2.0, v + boost * 0.3))
elif roi > 2.0: elif roi > 2.0:
penalty = (roi - 2.0) * 0.5 penalty = (roi - 2.0) * 0.5
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty) rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty)
for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
v = getattr(rpu, r) setattr(rpu, r, max(0.3, getattr(rpu, r) - penalty * 0.5))
setattr(rpu, r, max(0.3, v - penalty * 0.5))
streak = rpu.current_streak streak = rpu.current_streak
if streak <= -5: if streak <= -5:
boost = min(0.3, abs(streak) * 0.03) boost = min(0.3, abs(streak) * 0.03)
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost) rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
rpu.classified_multiplier = min(2.0, rpu.classified_multiplier + boost * 0.5) 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.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) rpu.rare_special_multiplier = min(1.5, rpu.rare_special_multiplier + boost * 0.3)
if streak >= 5: if streak >= 5:
penalty = min(0.2, streak * 0.02) penalty = min(0.2, streak * 0.02)
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty) rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty)
for r in ['classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: for r in ['classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
v = getattr(rpu, r) setattr(rpu, r, max(0.2, getattr(rpu, r) - penalty * 0.5))
setattr(rpu, r, max(0.2, v - penalty * 0.5))
if rpu.total_opened > 50: if rpu.total_opened > 50:
rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05) rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05)
rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03) 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', for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier',
'restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'restricted_multiplier', 'classified_multiplier', 'covert_multiplier',
'rare_special_multiplier']: 'rare_special_multiplier']:
value = getattr(rpu, field) setattr(rpu, field, max(0.1, min(3.0, 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.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier))
rpu.last_adjustment = datetime.utcnow() 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, async def update_rpu_stats(db: AsyncSession, user_id: int, spent: float, opened: int = 1,
received_value: float = 0.0, rarity: str = None, received_value: float = 0.0, rarity: str = None, is_rare: bool = False):
is_rare: bool = False): rpu = await get_user_rpu(db, user_id)
rpu = get_user_rpu(db, user_id) result = await db.execute(select(User).where(User.id == user_id))
user = db.query(User).filter(User.id == user_id).first() user = result.scalar_one_or_none()
rpu.total_spent += spent rpu.total_spent += spent
rpu.total_opened += opened 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: if rpu.current_streak < rpu.worst_streak:
rpu.worst_streak = rpu.current_streak rpu.worst_streak = rpu.current_streak
# ── RPU v2: Luck Budget ── await consume_luck_budget(db, user_id, received_value)
consume_luck_budget(db, user_id, received_value) await regen_luck_budget(db, user_id)
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 user and is_rare and received_value > 0:
if check_ceiling_breach(db, user_id, received_value, spent): if await check_ceiling_breach(db, user_id, received_value, spent):
apply_ceiling_breach(db, user_id, received_value) await apply_ceiling_breach(db, user_id, received_value)
# ── RPU v2: Hot/Cold ── await analyze_hot_cold(db, user_id)
analyze_hot_cold(db, user_id) await _rpu_commit_or_flush_async(db)
_rpu_commit_or_flush(db)
if rpu.auto_adjust: 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: async def get_rpu_v2_info(db: AsyncSession, user_id: int) -> dict:
""" await check_and_reset_session(db, user_id)
Возвращает полную информацию о RPU v2 для профиля/админки. rpu = await get_user_rpu(db, user_id)
""" result = await db.execute(select(User).where(User.id == user_id))
check_and_reset_session(db, user_id) user = result.scalar_one_or_none()
rpu = get_user_rpu(db, user_id)
user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
return {} return {}
ceiling = calculate_ceiling(db, user_id) ceiling = await calculate_ceiling(db, user_id)
inv_value = get_total_inventory_value(db, user_id) inv_value = await get_total_inventory_value(db, user_id)
total_value = user.balance + inv_value 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 session_count = max(0, (datetime.utcnow() - user.created_at).days) if user.created_at else 0
if user.created_at:
session_count = max(0, (datetime.utcnow() - user.created_at).days)
return { return {
"rpu_level": round(calculate_rpu_level(rpu)), "rpu_level": round(calculate_rpu_level(rpu)),
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# CS2 Simulator — Production Start Script
# Starts: PostgreSQL-backed web app + WebSocket server
set -e
# Ensure PostgreSQL is running
pg_isready -q || service postgresql start
# Ensure Redis is running
redis-cli ping > /dev/null 2>&1 || redis-server --daemonize yes
# Set production env
export USE_ASYNC=1
export ASYNC_DATABASE_URL="${ASYNC_DATABASE_URL:-postgresql+asyncpg://dodep:dodep123@localhost/dodep}"
export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}"
echo "=== Starting CS2 Simulator ==="
echo "DB: $ASYNC_DATABASE_URL"
echo "WS Server: port 13670"
echo "Web App: port 13669"
# Kill old processes
pkill -f "uvicorn frontend:app" 2>/dev/null || true
pkill -f "uvicorn ws_server:app" 2>/dev/null || true
sleep 1
# Start WebSocket server
nohup venv/bin/python -m uvicorn ws_server:app --host 0.0.0.0 --port 13670 --workers 1 > ws_server.log 2>&1 &
echo "[WS] Started on port 13670"
# Start main web app with multiple workers
nohup venv/bin/python -m uvicorn frontend:app --host 0.0.0.0 --port 13669 --workers 4 --loop uvloop > frontend.log 2>&1 &
echo "[WEB] Started on port 13669 (4 workers)"
echo "=== Ready ==="
echo "Web: http://localhost:13669"
echo "WS: ws://localhost:13670/ws/{user_id}"
+1
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
async function logout(){try{await fetch('/web/api/auth/logout',{method:'POST'});}catch(e){}
window.location.href='/';}
async function refreshBalance(){try{const res=await fetch('/web/api/user/balance');const data=await res.json();if(data.success){const el=document.getElementById('siteBalanceDisplay');if(el){el.innerHTML=Math.floor(data.balance).toLocaleString('ru')+' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';}}}catch(e){}}
function getRarityColor(rarity){const colors={'Consumer Grade':'#b0b0b0','Industrial Grade':'#5e98d9','Mil-Spec':'#4b69ff','Mil-Spec Grade':'#4b69ff','Restricted':'#8847ff','Classified':'#d32ce6','Covert':'#eb4b4b','Rare Special Item':'#ffd700','Extraordinary':'#ffd700',};return colors[rarity]||'#b0b0b0';}
function formatNumber(n){return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g,' ');}
function debounce(fn,ms=300){let timer;return function(...args){clearTimeout(timer);timer=setTimeout(()=>fn.apply(this,args),ms);};}
function escapeHtml(str){const div=document.createElement('div');div.textContent=str;return div.innerHTML;}
function showNotification(message,type='info',duration=3000){const existing=document.querySelector('.notification-toast');if(existing)existing.remove();const colors={success:'#22c55e',error:'#ef4444',info:'#3b82f6',warning:'#f59e0b',};const toast=document.createElement('div');toast.className='notification-toast';toast.style.cssText=`position:fixed;top:20px;right:20px;z-index:99999;background:#1a1a2e;border:1px solid ${colors[type]||colors.info};color:white;padding:12px 20px;border-radius:8px;font-size:14px;box-shadow:0 10px 40px rgba(0,0,0,0.5);animation:slideInRight 0.3s ease;max-width:400px;`;toast.textContent=message;document.body.appendChild(toast);setTimeout(()=>{toast.style.animation='slideOutRight 0.3s ease forwards';setTimeout(()=>toast.remove(),300);},duration);}
if(!document.getElementById('notificationStyles')){const style=document.createElement('style');style.id='notificationStyles';style.textContent=`@keyframes slideInRight{from{transform:translateX(120%);opacity:0;}
to{transform:translateX(0);opacity:1;}}@keyframes slideOutRight{from{transform:translateX(0);opacity:1;}
to{transform:translateX(120%);opacity:0;}}`;document.head.appendChild(style);}
function showAchievementPopup(title,reward){let popup=document.getElementById('achievementGlobalPopup');if(!popup){popup=document.createElement('div');popup.id='achievementGlobalPopup';popup.style.cssText=`position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:99999;background:linear-gradient(135deg,#1a3a1a,#0d260d);border:1px solid#22c55e;border-radius:12px;padding:1rem 1.5rem;box-shadow:0 10px 40px rgba(0,0,0,0.5);animation:slideInDown 0.5s ease;max-width:400px;width:90%;text-align:center;`;popup.innerHTML=`<div style="font-size:0.85rem;color:#94a3b8;margin-bottom:0.25rem;">🏆 Новое достижение!</div><div style="font-size:1.1rem;font-weight:600;"id="globalPopupTitle"></div><div style="color:#22c55e;font-size:0.9rem;margin-top:0.25rem;"id="globalPopupReward"></div>`;document.body.appendChild(popup);if(!document.getElementById('slideInDownStyle')){const s=document.createElement('style');s.id='slideInDownStyle';s.textContent=`@keyframes slideInDown{from{transform:translateX(-50%)translateY(-120%);opacity:0;}
to{transform:translateX(-50%)translateY(0);opacity:1;}}`;document.head.appendChild(s);}}
document.getElementById('globalPopupTitle').textContent=title;document.getElementById('globalPopupReward').textContent='';popup.style.display='block';if(window.SoundManager)SoundManager.achievement();setTimeout(()=>{popup.style.animation='slideInDown 0.5s ease reverse forwards';setTimeout(()=>{popup.style.display='none';popup.style.animation='slideInDown 0.5s ease';},500);},4000);}
+19
View File
@@ -0,0 +1,19 @@
(function(){'use strict';let toastId=0;const container=document.createElement('div');container.className='toast-container';container.id='toastContainer';document.body.appendChild(container);function escapeHtml(text){const d=document.createElement('div');d.textContent=text||'';return d.innerHTML;}
window.Notify={toast:function(message,type,title){if(!message)return;type=type||'info';const icons={success:'✓',error:'✕',info:'',warning:'⚠'};const titles={success:title||'Успешно',error:title||'Ошибка',info:title||'Информация',warning:title||'Внимание'};const icon=icons[type]||'';const t=titles[type];const id=++toastId;const el=document.createElement('div');el.className='toast toast-'+type;el.id='toast-'+id;el.innerHTML='<div class="toast-icon">'+icon+'</div>'+
'<div class="toast-content">'+
'<div class="toast-title">'+escapeHtml(t)+'</div>'+
'<div class="toast-message">'+escapeHtml(message)+'</div>'+
'</div>'+
'<button class="toast-close" onclick="Notify.dismiss('+id+')">✕</button>';container.appendChild(el);const timeout=setTimeout(function(){Notify.dismiss(id);},4000);el._timeout=timeout;el._id=id;return id;},success:function(message){return this.toast(message,'success');},error:function(message){return this.toast(message,'error');},info:function(message){return this.toast(message,'info');},warning:function(message){return this.toast(message,'warning');},dismiss:function(id){var el=document.getElementById('toast-'+id);if(!el)return;if(el._timeout)clearTimeout(el._timeout);if(el.classList.contains('toast-removing'))return;el.classList.add('toast-removing');setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},250);},confirm:function(message,title,callback){if(typeof title==='function'){callback=title;title='Подтверждение';}
title=title||'Подтверждение';var overlay=document.createElement('div');overlay.className='confirm-overlay';overlay.innerHTML='<div class="confirm-dialog">'+
'<div class="confirm-dialog-header">'+
'<div class="confirm-dialog-icon confirm-dialog-icon-warning">⚠</div>'+
'<div class="confirm-dialog-title">'+escapeHtml(title)+'</div>'+
'</div>'+
'<div class="confirm-dialog-message">'+escapeHtml(message)+'</div>'+
'<div class="confirm-dialog-actions">'+
'<button class="btn btn-outline confirm-cancel">Отмена</button>'+
'<button class="btn btn-danger confirm-ok">Подтвердить</button>'+
'</div>'+
'</div>';document.body.appendChild(overlay);var result=false;function close(){if(overlay.parentNode)overlay.parentNode.removeChild(overlay);if(callback)callback(result);}
overlay.querySelector('.confirm-cancel').addEventListener('click',function(){result=false;close();});overlay.querySelector('.confirm-ok').addEventListener('click',function(){result=true;close();});overlay.addEventListener('click',function(e){if(e.target===overlay){result=false;close();}});document.addEventListener('keydown',function handler(e){if(e.key==='Escape'){result=false;close();document.removeEventListener('keydown',handler);}});return overlay;}};})();
+4
View File
@@ -0,0 +1,4 @@
const SoundManager={_ctx:null,_enabled:true,_volume:0.3,_activeNodes:new Set(),_timeouts:[],init(){try{this._ctx=new(window.AudioContext||window.webkitAudioContext)();}catch(e){console.warn('Web Audio API not supported');this._enabled=false;}
const saved=localStorage.getItem('sound_enabled');if(saved!==null)this._enabled=saved==='true';},setEnabled(on){this._enabled=on;localStorage.setItem('sound_enabled',on);},toggle(){this.setEnabled(!this._enabled);return this._enabled;},stopAll(){this._timeouts.forEach(clearTimeout);this._timeouts=[];this._activeNodes.forEach(node=>{try{node.stop();}catch(e){}
try{node.disconnect();}catch(e){}});this._activeNodes.clear();},_trackNode(node,duration){this._activeNodes.add(node);node.addEventListener('ended',()=>this._activeNodes.delete(node));setTimeout(()=>{this._activeNodes.delete(node);try{node.disconnect();}catch(e){}},(duration+0.1)*1000);},_play(freq,duration,type='sine',volume=1){if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();const osc=this._ctx.createOscillator();const gain=this._ctx.createGain();osc.type=type;osc.frequency.setValueAtTime(freq,this._ctx.currentTime);gain.gain.setValueAtTime(this._volume*volume,this._ctx.currentTime);gain.gain.exponentialRampToValueAtTime(0.001,this._ctx.currentTime+duration);osc.connect(gain);gain.connect(this._ctx.destination);osc.start();osc.stop(this._ctx.currentTime+duration);this._trackNode(osc,duration);this._trackNode(gain,duration);},_noise(duration,volume=1){if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();const bufferSize=this._ctx.sampleRate*duration;const buffer=this._ctx.createBuffer(1,bufferSize,this._ctx.sampleRate);const data=buffer.getChannelData(0);for(let i=0;i<bufferSize;i++){data[i]=Math.random()*2-1;}
const source=this._ctx.createBufferSource();source.buffer=buffer;const gain=this._ctx.createGain();gain.gain.setValueAtTime(this._volume*volume,this._ctx.currentTime);gain.gain.exponentialRampToValueAtTime(0.001,this._ctx.currentTime+duration);source.connect(gain);gain.connect(this._ctx.destination);source.start();this._trackNode(source,duration);this._trackNode(gain,duration);},_delay(fn,ms){const id=setTimeout(()=>{this._timeouts=this._timeouts.filter(t=>t!==id);fn();},ms);this._timeouts.push(id);return id;},caseOpen(){this._play(800,0.1,'square',0.3);this._delay(()=>this._play(400,0.15,'sawtooth',0.2),80);this._delay(()=>this._play(200,0.2,'sine',0.15),180);this._noise(0.05,0.4);},caseRare(){this._play(523,0.15,'sine',0.3);this._delay(()=>this._play(659,0.15,'sine',0.3),100);this._delay(()=>this._play(784,0.15,'sine',0.3),200);this._delay(()=>this._play(1047,0.3,'sine',0.4),300);},caseCovert(){this._play(392,0.2,'sawtooth',0.3);this._delay(()=>this._play(523,0.2,'sawtooth',0.3),150);this._delay(()=>this._play(659,0.2,'sawtooth',0.3),300);this._delay(()=>this._play(784,0.4,'sine',0.5),450);this._delay(()=>this._play(1047,0.6,'sine',0.6),600);},slotSpin(){for(let i=0;i<8;i++){this._delay(()=>{this._play(300+Math.random()*400,0.05,'square',0.15);},i*80);}},slotMatch(){this._play(523,0.1,'sine',0.3);this._delay(()=>this._play(659,0.1,'sine',0.3),100);this._delay(()=>this._play(784,0.1,'sine',0.3),200);this._delay(()=>this._play(1047,0.4,'sine',0.5),300);},contractSubmit(){this._play(150,0.3,'sawtooth',0.2);this._delay(()=>this._play(200,0.2,'square',0.2),200);this._delay(()=>this._play(250,0.1,'square',0.15),350);},contractResult(){this._play(400,0.15,'sine',0.3);this._delay(()=>this._play(600,0.15,'sine',0.3),120);this._delay(()=>this._play(800,0.3,'sine',0.4),240);},wheelSpin(){this._play(600+Math.random()*400,0.04,'triangle',0.12);this._play(200,0.02,'square',0.06);},wheelWin(){const t=this._ctx.currentTime;if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();[523,659,784,1047].forEach((freq,i)=>{const osc=this._ctx.createOscillator();const gain=this._ctx.createGain();osc.type='sine';osc.frequency.setValueAtTime(freq,t+i*0.1);gain.gain.setValueAtTime(0,t+i*0.1);gain.gain.linearRampToValueAtTime(this._volume*0.35,t+i*0.1+0.05);gain.gain.exponentialRampToValueAtTime(0.001,t+i*0.1+0.5);osc.connect(gain);gain.connect(this._ctx.destination);osc.start(t+i*0.1);osc.stop(t+i*0.1+0.5);this._trackNode(osc,0.6);this._trackNode(gain,0.6);});this._delay(()=>this._play(1568,0.4,'sine',0.25),350);this._delay(()=>this._play(2093,0.6,'sine',0.2),500);},wheelLose(){this._play(300,0.15,'sawtooth',0.25);this._delay(()=>this._play(200,0.2,'sawtooth',0.2),100);this._delay(()=>this._play(120,0.3,'sawtooth',0.15),220);this._noise(0.15,0.3);this._delay(()=>{this._play(60,0.5,'sine',0.2);this._play(45,0.6,'sine',0.15);},350);},achievement(){this._play(659,0.1,'sine',0.3);this._delay(()=>this._play(523,0.1,'sine',0.3),100);this._delay(()=>this._play(784,0.1,'sine',0.3),200);this._delay(()=>this._play(659,0.1,'sine',0.3),300);this._delay(()=>this._play(1047,0.4,'sine',0.5),400);},crashBet(){this._play(300,0.1,'square',0.15);},crashTick(){this._play(500+Math.random()*500,0.03,'sine',0.08);},crashCrashed(){this._noise(0.3,0.6);this._play(80,0.5,'sawtooth',0.4);this._delay(()=>this._play(50,0.8,'sine',0.3),100);},crashCashout(){this._play(600,0.1,'sine',0.3);this._delay(()=>this._play(800,0.1,'sine',0.3),80);this._delay(()=>this._play(1000,0.2,'sine',0.4),160);},click(){this._play(800,0.03,'sine',0.1);},error(){this._play(200,0.15,'square',0.2);},balanceUpdate(){this._play(1000,0.05,'sine',0.1);}};document.addEventListener('DOMContentLoaded',()=>SoundManager.init());
+4
View File
@@ -0,0 +1,4 @@
const WS={_ws:null,_reconnectTimer:null,_listeners:{},_connected:false,connect(){if(this._ws&&this._ws.readyState===WebSocket.OPEN)return;const protocol=window.location.protocol==='https:'?'wss:':'ws:';const url=`${protocol}
try{this._ws=new WebSocket(url);}catch(e){console.warn('WS connection failed, retrying in 5s');this._scheduleReconnect();return;}
this._ws.onopen=()=>{this._connected=true;this._emit('connected');};this._ws.onclose=()=>{this._connected=false;this._emit('disconnected');this._scheduleReconnect();};this._ws.onerror=()=>{this._emit('error');};this._ws.onmessage=(event)=>{try{const data=JSON.parse(event.data);this._emit(data.type,data);}catch(e){}};this._pingInterval=setInterval(()=>{if(this._ws&&this._ws.readyState===WebSocket.OPEN){this._ws.send(JSON.stringify({type:'ping'}));}},30000);},disconnect(){if(this._pingInterval)clearInterval(this._pingInterval);if(this._reconnectTimer)clearTimeout(this._reconnectTimer);if(this._ws){this._ws.onclose=null;this._ws.close();this._ws=null;}
this._connected=false;},_scheduleReconnect(){if(this._reconnectTimer)return;this._reconnectTimer=setTimeout(()=>{this._reconnectTimer=null;this.connect();},5000);},on(event,callback){if(!this._listeners[event])this._listeners[event]=[];this._listeners[event].push(callback);return()=>{this._listeners[event]=this._listeners[event].filter(cb=>cb!==callback);};},_emit(event,data){(this._listeners[event]||[]).forEach(cb=>cb(data));(this._listeners['*']||[]).forEach(cb=>cb(event,data));},isConnected(){return this._connected;}};document.addEventListener('DOMContentLoaded',()=>WS.connect());
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Достижения - CS2 Simulator</title> <title>Достижения - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
<style> <style>
.achievement-card.secret { .achievement-card.secret {
border-color: #8b5cf6; border-color: #8b5cf6;
@@ -283,9 +283,9 @@
<div class="popup-reward" id="popupReward"></div> <div class="popup-reward" id="popupReward"></div>
</div> </div>
<script src="/static/js/sounds.js"></script> <script src="/static/js/sounds.min.js"></script>
<script src="/static/js/websocket.js"></script> <script src="/static/js/websocket.min.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.min.js"></script>
<script src="/static/js/safemode.js"></script> <script src="/static/js/safemode.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Админ-панель{% endblock %} — CS2 Simulator</title> <title>{% block title %}Админ-панель{% endblock %} — CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; } html { font-size: 15px; }
@@ -470,7 +470,7 @@
</div> </div>
{% block modals %}{% endblock %} {% block modals %}{% endblock %}
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.min.js"></script>
<script> <script>
(function() { (function() {
const sidebar = document.getElementById('adminSidebar'); const sidebar = document.getElementById('adminSidebar');
+5 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover">
<title>{{ case_display_name }} - CS2 Simulator</title> <title>{{ case_display_name }} - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
<style> <style>
/* Стили для карточек (только для фарм-кейсов) */ /* Стили для карточек (только для фарм-кейсов) */
.cards-grid { .cards-grid {
@@ -1875,11 +1875,11 @@
} }
}); });
</script> </script>
<script src="/static/js/sounds.js"></script> <script src="/static/js/sounds.min.js"></script>
<script src="/static/js/websocket.js"></script> <script src="/static/js/websocket.min.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.min.js"></script>
<script src="/static/js/safemode.js"></script> <script src="/static/js/safemode.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.min.js"></script>
<script> <script>
function handleAchievements(data) { function handleAchievements(data) {
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) { if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Кейсы - CS2 Simulator</title> <title>Кейсы - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
<style> <style>
.cases-sections { .cases-sections {
display: flex; display: flex;
@@ -360,9 +360,9 @@
window.location.href = '/'; window.location.href = '/';
} }
</script> </script>
<script src="/static/js/sounds.js"></script> <script src="/static/js/sounds.min.js"></script>
<script src="/static/js/websocket.js"></script> <script src="/static/js/websocket.min.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.min.js"></script>
<script src="/static/js/safemode.js"></script> <script src="/static/js/safemode.js"></script>
<script> <script>
function toggleSound() { function toggleSound() {
+5 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Контракты — CS2 Simulator</title> <title>Контракты — CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
</head> </head>
<body> <body>
{% include "_nav.html" %} {% include "_nav.html" %}
@@ -704,10 +704,10 @@
}); });
</script> </script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.min.js"></script>
<script src="/static/js/sounds.js"></script> <script src="/static/js/sounds.min.js"></script>
<script src="/static/js/websocket.js"></script> <script src="/static/js/websocket.min.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.min.js"></script>
<script src="/static/js/safemode.js"></script> <script src="/static/js/safemode.js"></script>
{% if user %}{% include '_activity_sidebar.html' %}{% endif %} {% if user %}{% include '_activity_sidebar.html' %}{% endif %}
</body> </body>
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crash - CS2 Simulator</title> <title>Crash - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
<style> <style>
.crash-layout { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; } .crash-layout { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
.crash-canvas-container { .crash-canvas-container {
@@ -116,9 +116,9 @@
</div> </div>
</main> </main>
<script src="/static/js/sounds.js"></script> <script src="/static/js/sounds.min.js"></script>
<script src="/static/js/websocket.js"></script> <script src="/static/js/websocket.min.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.min.js"></script>
<script src="/static/js/safemode.js"></script> <script src="/static/js/safemode.js"></script>
<script> <script>
const canvas = document.getElementById('crashCanvas'); const canvas = document.getElementById('crashCanvas');
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CS2 Trade-Up Simulator</title> <title>CS2 Trade-Up Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
</head> </head>
<body> <body>
{% include "_nav.html" %} {% include "_nav.html" %}
@@ -39,7 +39,7 @@
</div> </div>
</main> </main>
<script src="/static/js/app.js"></script> <script src="/static/js/app.min.js"></script>
<script src="/static/js/safemode.js"></script> <script src="/static/js/safemode.js"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход - CS2 Simulator</title> <title>Вход - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
</head> </head>
<body> <body>
{% include "_nav.html" %} {% include "_nav.html" %}
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if is_public %}Профиль {{ profile_user.username }}{% else %}Профиль{% endif %} - CS2 Simulator</title> <title>{% if is_public %}Профиль {{ profile_user.username }}{% else %}Профиль{% endif %} - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
<style> <style>
.profile-layout { .profile-layout {
display: grid; display: grid;
@@ -820,9 +820,9 @@ data-price="{{ item.price_rub }}">
</div> </div>
</main> </main>
<script src="/static/js/sounds.js"></script> <script src="/static/js/sounds.min.js"></script>
<script src="/static/js/websocket.js"></script> <script src="/static/js/websocket.min.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.min.js"></script>
<script src="/static/js/safemode.js"></script> <script src="/static/js/safemode.js"></script>
<script> <script>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Регистрация - CS2 Simulator</title> <title>Регистрация - CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
</head> </head>
<body> <body>
{% include "_nav.html" %} {% include "_nav.html" %}
+5 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover">
<title>Апгрейд — CS2 Simulator</title> <title>Апгрейд — CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.min.css">
</head> </head>
<body class="upgrade-page"> <body class="upgrade-page">
{% include "_nav.html" %} {% include "_nav.html" %}
@@ -759,11 +759,11 @@
window.location.href = '/'; window.location.href = '/';
} }
</script> </script>
<script src="/static/js/sounds.js"></script> <script src="/static/js/sounds.min.js"></script>
<script src="/static/js/websocket.js"></script> <script src="/static/js/websocket.min.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.min.js"></script>
<script src="/static/js/safemode.js"></script> <script src="/static/js/safemode.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.min.js"></script>
<script> <script>
function handleAchievements(data) { function handleAchievements(data) {
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) { if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
+59 -40
View File
@@ -1,7 +1,9 @@
import random import random
import math import math
from datetime import datetime from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from database import User, UpgradeRPU, Upgrade from database import User, UpgradeRPU, Upgrade
MAX_WIN_STREAK = 3 MAX_WIN_STREAK = 3
@@ -9,7 +11,6 @@ MAX_LOSE_STREAK = 5
def get_upgrade_rpu(db: Session, user_id: int) -> UpgradeRPU: def get_upgrade_rpu(db: Session, user_id: int) -> UpgradeRPU:
"""Получает или создает настройки РПУ апгрейдов"""
rpu = db.query(UpgradeRPU).filter(UpgradeRPU.user_id == user_id).first() rpu = db.query(UpgradeRPU).filter(UpgradeRPU.user_id == user_id).first()
if not rpu: if not rpu:
rpu = UpgradeRPU(user_id=user_id) rpu = UpgradeRPU(user_id=user_id)
@@ -20,82 +21,80 @@ def get_upgrade_rpu(db: Session, user_id: int) -> UpgradeRPU:
return rpu return rpu
async def get_upgrade_rpu_async(db: AsyncSession, user_id: int) -> UpgradeRPU:
result = await db.execute(select(UpgradeRPU).where(UpgradeRPU.user_id == user_id))
rpu = result.scalar_one_or_none()
if not rpu:
rpu = UpgradeRPU(user_id=user_id)
db.add(rpu)
from rpu import _rpu_commit_or_flush_async
await _rpu_commit_or_flush_async(db)
await db.refresh(rpu)
return rpu
def calculate_upgrade_probability(input_price: float, target_price: float) -> float: def calculate_upgrade_probability(input_price: float, target_price: float) -> float:
if input_price <= 0 or target_price <= 0: if input_price <= 0 or target_price <= 0:
return 50.0 return 50.0
if target_price <= input_price: if target_price <= input_price:
return -1.0 return -1.0
probability = (input_price / target_price) * 100 probability = (input_price / target_price) * 100
return max(1.0, min(75.0, probability)) return max(1.0, min(75.0, probability))
def get_adjusted_upgrade_probability(db: Session, user_id: int, base_probability: float) -> float: def get_adjusted_upgrade_probability(db: Session, user_id: int, base_probability: float) -> float:
if base_probability < 0: if base_probability < 0:
return -1.0 return -1.0
rpu = get_upgrade_rpu(db, user_id) rpu = get_upgrade_rpu(db, user_id)
# Базовая корректировка от множителя
adjusted = base_probability * rpu.upgrade_multiplier adjusted = base_probability * rpu.upgrade_multiplier
# Корректировка на серии
if rpu.current_win_streak >= MAX_WIN_STREAK: if rpu.current_win_streak >= MAX_WIN_STREAK:
# Слишком много побед подряд — режем шанс
penalty = (rpu.current_win_streak - MAX_WIN_STREAK + 1) * 0.15 penalty = (rpu.current_win_streak - MAX_WIN_STREAK + 1) * 0.15
adjusted *= (1.0 - penalty) adjusted *= (1.0 - penalty)
elif rpu.current_lose_streak >= MAX_LOSE_STREAK: elif rpu.current_lose_streak >= MAX_LOSE_STREAK:
# Слишком много поражений подряд — повышаем шанс
boost = (rpu.current_lose_streak - MAX_LOSE_STREAK + 1) * 0.2 boost = (rpu.current_lose_streak - MAX_LOSE_STREAK + 1) * 0.2
adjusted *= (1.0 + boost) adjusted *= (1.0 + boost)
return max(1.0, min(75.0, adjusted))
async def get_adjusted_upgrade_probability_async(db: AsyncSession, user_id: int, base_probability: float) -> float:
if base_probability < 0:
return -1.0
rpu = await get_upgrade_rpu_async(db, user_id)
adjusted = base_probability * rpu.upgrade_multiplier
if rpu.current_win_streak >= MAX_WIN_STREAK:
penalty = (rpu.current_win_streak - MAX_WIN_STREAK + 1) * 0.15
adjusted *= (1.0 - penalty)
elif rpu.current_lose_streak >= MAX_LOSE_STREAK:
boost = (rpu.current_lose_streak - MAX_LOSE_STREAK + 1) * 0.2
adjusted *= (1.0 + boost)
return max(1.0, min(75.0, adjusted)) return max(1.0, min(75.0, adjusted))
def spin_upgrade_wheel(success_probability: float) -> tuple[bool, float, int, float]: def spin_upgrade_wheel(success_probability: float) -> tuple[bool, float, int, float]:
raw_angle = random.uniform(0, 360) raw_angle = random.uniform(0, 360)
half_green = (success_probability / 100) * 180 half_green = (success_probability / 100) * 180
success = 180 - half_green <= raw_angle <= 180 + half_green success = 180 - half_green <= raw_angle <= 180 + half_green
margin = max(3, half_green * 0.12) margin = max(3, half_green * 0.12)
if success: if success:
rotations = random.randint(7, 9) rotations = random.randint(7, 9)
lo = max(0, 180 - half_green + margin) lo = max(0, 180 - half_green + margin)
hi = min(360, 180 + half_green - margin) hi = min(360, 180 + half_green - margin)
if lo < hi: target_angle = random.uniform(lo, hi) if lo < hi else random.uniform(
target_angle = random.uniform(lo, hi) max(0, 180 - half_green + 1), min(360, 180 + half_green - 1))
else:
target_angle = random.uniform(max(0, 180 - half_green + 1), min(360, 180 + half_green - 1))
else: else:
rotations = random.randint(6, 8) rotations = random.randint(6, 8)
left_size = max(0, 180 - half_green - 3) left_size = max(0, 180 - half_green - 3)
right_start = min(360, 180 + half_green + 3) right_start = min(360, 180 + half_green + 3)
right_size = max(0, 360 - right_start) right_size = max(0, 360 - right_start)
if left_size >= right_size: lo, hi = (0, max(0, 180 - half_green - 3)) if left_size >= right_size else (min(360, 180 + half_green + 3), 360)
lo = 0 target_angle = random.uniform(lo, hi) if lo < hi else random.uniform(min(360, 180 + half_green + 3), 360)
hi = max(0, 180 - half_green - 3)
else:
lo = min(360, 180 + half_green + 3)
hi = 360
if lo < hi:
target_angle = random.uniform(lo, hi)
else:
target_angle = random.uniform(0, 360)
final_angle = target_angle + rotations * 360 final_angle = target_angle + rotations * 360
return success, final_angle, rotations, raw_angle return success, final_angle, rotations, raw_angle
def update_upgrade_stats(db: Session, user_id: int, success: bool, input_price: float): def update_upgrade_stats(db: Session, user_id: int, success: bool, input_price: float):
rpu = get_upgrade_rpu(db, user_id) rpu = get_upgrade_rpu(db, user_id)
rpu.total_attempts += 1 rpu.total_attempts += 1
if success: if success:
rpu.total_success += 1 rpu.total_success += 1
rpu.current_win_streak += 1 rpu.current_win_streak += 1
@@ -108,24 +107,44 @@ def update_upgrade_stats(db: Session, user_id: int, success: bool, input_price:
rpu.current_win_streak = 0 rpu.current_win_streak = 0
if rpu.current_lose_streak > rpu.worst_lose_streak: if rpu.current_lose_streak > rpu.worst_lose_streak:
rpu.worst_lose_streak = rpu.current_lose_streak rpu.worst_lose_streak = rpu.current_lose_streak
# Авто-подкрутка
if rpu.auto_adjust and rpu.total_attempts >= 10: if rpu.auto_adjust and rpu.total_attempts >= 10:
# Анализ серии побед
if rpu.current_win_streak >= MAX_WIN_STREAK: if rpu.current_win_streak >= MAX_WIN_STREAK:
rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.15) rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.15)
# Анализ серии поражений
if rpu.current_lose_streak >= MAX_LOSE_STREAK: if rpu.current_lose_streak >= MAX_LOSE_STREAK:
rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.2) rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.2)
# Общий процент побед
rate = rpu.total_success / rpu.total_attempts rate = rpu.total_success / rpu.total_attempts
if rate < 0.2: if rate < 0.2:
rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.1) rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.1)
elif rate > 0.6: elif rate > 0.6:
rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.1) rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.1)
from rpu import _rpu_commit_or_flush from rpu import _rpu_commit_or_flush
_rpu_commit_or_flush(db) _rpu_commit_or_flush(db)
async def update_upgrade_stats_async(db: AsyncSession, user_id: int, success: bool, input_price: float):
rpu = await get_upgrade_rpu_async(db, user_id)
rpu.total_attempts += 1
if success:
rpu.total_success += 1
rpu.current_win_streak += 1
rpu.current_lose_streak = 0
if rpu.current_win_streak > rpu.best_win_streak:
rpu.best_win_streak = rpu.current_win_streak
else:
rpu.total_spent_value += input_price
rpu.current_lose_streak += 1
rpu.current_win_streak = 0
if rpu.current_lose_streak > rpu.worst_lose_streak:
rpu.worst_lose_streak = rpu.current_lose_streak
if rpu.auto_adjust and rpu.total_attempts >= 10:
if rpu.current_win_streak >= MAX_WIN_STREAK:
rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.15)
if rpu.current_lose_streak >= MAX_LOSE_STREAK:
rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.2)
rate = rpu.total_success / rpu.total_attempts
if rate < 0.2:
rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.1)
elif rate > 0.6:
rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.1)
from rpu import _rpu_commit_or_flush_async
await _rpu_commit_or_flush_async(db)
+254
View File
@@ -0,0 +1,254 @@
import os
import json
import asyncio
import random
import math
import time
from typing import Set, Dict, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from contextlib import asynccontextmanager
import aioredis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CRASH_SPEED = 0.08
CRASH_MIN_WAIT = 5.0
CRASH_MAX_WAIT = 10.0
CRASH_RUNNING_TIME = 60.0
CRASH_COOLDOWN = 3.0
app = FastAPI(title="CS2 Simulator WebSocket Server")
class RedisPubSub:
def __init__(self):
self.pub: Optional[aioredis.Redis] = None
self.sub: Optional[aioredis.Redis] = None
async def connect(self):
self.pub = await aioredis.from_url(REDIS_URL, decode_responses=True)
self.sub = await aioredis.from_url(REDIS_URL, decode_responses=True)
async def publish(self, channel: str, message: dict):
if self.pub:
await self.pub.publish(channel, json.dumps(message, default=str))
async def subscribe(self, channel: str):
if self.sub:
psub = self.sub.pubsub()
await psub.subscribe(channel)
return psub
return None
async def close(self):
if self.pub:
await self.pub.close()
if self.sub:
await self.sub.close()
redis_pubsub = RedisPubSub()
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[int, Set[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, user_id: int):
await websocket.accept()
async with self._lock:
if user_id not in self.active_connections:
self.active_connections[user_id] = set()
self.active_connections[user_id].add(websocket)
async def disconnect(self, websocket: WebSocket, user_id: int):
async with self._lock:
if user_id in self.active_connections:
self.active_connections[user_id].discard(websocket)
if not self.active_connections[user_id]:
del self.active_connections[user_id]
async def send_personal(self, user_id: int, message: dict):
async with self._lock:
if user_id in self.active_connections:
dead = set()
for ws in self.active_connections[user_id]:
try:
await ws.send_json(message)
except Exception:
dead.add(ws)
self.active_connections[user_id] -= dead
if not self.active_connections[user_id]:
del self.active_connections[user_id]
async def broadcast(self, message: dict):
async with self._lock:
dead: Dict[int, Set[WebSocket]] = {}
for uid, sockets in self.active_connections.items():
for ws in sockets:
try:
await ws.send_json(message)
except Exception:
dead.setdefault(uid, set()).add(ws)
for uid, dset in dead.items():
self.active_connections[uid] -= dset
if not self.active_connections[uid]:
del self.active_connections[uid]
@property
def online_count(self) -> int:
return len(self.active_connections)
manager = ConnectionManager()
class CrashGame:
def __init__(self):
self.running = False
self.current_multiplier = 1.0
self.crash_point = 1.0
self.bets: Dict[int, float] = {}
self.cashed_out: Dict[int, float] = {}
self.phase = "waiting"
self.timer = 0.0
self.round = 0
self.history: list = []
self._start_time = 0.0
def generate_crash_point(self) -> float:
r = random.random()
cp = 1.0 / max(0.01, 1.0 - r * 0.99)
return round(min(cp, 1000.0), 2)
def get_multiplier(self, elapsed: float) -> float:
return round(math.exp(elapsed * CRASH_SPEED), 2)
def to_dict(self):
return {
"phase": self.phase,
"round": self.round,
"current_multiplier": self.current_multiplier,
"crash_point": self.crash_point,
"timer": int(self.timer),
"bet_count": len(self.bets),
"total_bets": round(sum(self.bets.values()), 2),
"cashed_out": {str(k): v for k, v in self.cashed_out.items()},
"history": self.history[-20:],
}
crash = CrashGame()
async def crash_game_loop():
while True:
if crash.phase == "waiting" or crash.phase == "cooldown":
wait_time = random.uniform(CRASH_MIN_WAIT, CRASH_MAX_WAIT)
for i in range(int(wait_time)):
crash.timer = wait_time - i
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
await asyncio.sleep(1)
crash.timer = 0
crash.phase = "betting"
crash.cashed_out.clear()
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
await asyncio.sleep(5)
crash.phase = "running"
crash.round += 1
crash.crash_point = crash.generate_crash_point()
crash.current_multiplier = 1.0
crash._start_time = time.time()
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
if crash.phase == "running":
while True:
elapsed = time.time() - crash._start_time
mult = crash.get_multiplier(elapsed)
crash.current_multiplier = mult
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
if mult >= crash.crash_point:
crash.phase = "crashed"
crash.history.append({
"round": crash.round,
"crash_point": crash.crash_point,
"timestamp": time.time(),
})
if len(crash.history) > 100:
crash.history = crash.history[-100:]
await manager.broadcast({
"type": "crash_crashed",
"crash_point": crash.crash_point,
"round": crash.round,
})
crash.bets.clear()
crash.phase = "cooldown"
break
if mult > 2.0:
await asyncio.sleep(0.15)
elif mult > 5.0:
await asyncio.sleep(0.08)
else:
await asyncio.sleep(0.2)
await asyncio.sleep(CRASH_COOLDOWN)
async def listen_activity():
psub = await redis_pubsub.subscribe("activity")
if psub:
async for msg in psub.listen():
if msg["type"] == "message":
data = json.loads(msg["data"])
await manager.broadcast(data)
@asynccontextmanager
async def lifespan(app_instance):
await redis_pubsub.connect()
asyncio.create_task(crash_game_loop())
asyncio.create_task(listen_activity())
yield
await redis_pubsub.close()
app.router.lifespan_context = lifespan
@app.websocket("/ws/{user_id}")
async def websocket_endpoint(websocket: WebSocket, user_id: int):
await manager.connect(websocket, user_id)
await websocket.send_json({"type": "connected", "user_id": user_id})
await websocket.send_json({"type": "crash_state", **crash.to_dict()})
try:
while True:
data = await websocket.receive_json()
msg_type = data.get("type")
if msg_type == "crash_bet":
amount = float(data.get("amount", 0))
if amount > 0 and crash.phase == "betting":
crash.bets[user_id] = crash.bets.get(user_id, 0) + amount
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
elif msg_type == "crash_cashout":
if crash.phase == "running" and user_id in crash.bets:
bet = crash.bets.pop(user_id)
crash.cashed_out[user_id] = crash.current_multiplier
payout = round(bet * crash.current_multiplier, 2)
await redis_pubsub.publish("crash_payout", {
"user_id": user_id,
"bet": bet,
"multiplier": crash.current_multiplier,
"payout": payout,
})
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
elif msg_type == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
await manager.disconnect(websocket, user_id)
crash.bets.pop(user_id, None)
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=13670)