PostgreSQL + async refactoring, WebSocket server, Redis caching, minified assets
This commit is contained in:
@@ -5,3 +5,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
venv/
|
||||||
|
|||||||
+677
-2437
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
@@ -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
|
||||||
+171
-215
@@ -1,16 +1,33 @@
|
|||||||
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"))
|
||||||
@@ -18,10 +35,25 @@ with engine.connect() as conn:
|
|||||||
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")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Create tables ──────────────────────────────────────────────────────────
|
||||||
|
def init_sync_db():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_run_migrations(engine, is_sync=True)
|
||||||
|
|
||||||
# Миграция: добавляем недостающие индексы для производительности
|
|
||||||
|
async def init_async_db():
|
||||||
|
async with async_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_migrations(engine_or_conn, is_sync: bool):
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
inspector = inspect(engine)
|
inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn)
|
||||||
indices_to_add = {
|
existing_tables = inspector.get_table_names()
|
||||||
"inventory_items": ["user_id"],
|
for tbl_name in Base.metadata.tables:
|
||||||
"case_openings": ["user_id", "opened_at"],
|
if tbl_name not in existing_tables:
|
||||||
"upgrades": ["user_id"],
|
continue
|
||||||
"contracts": ["user_id"],
|
model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns}
|
||||||
"activity_feed": ["user_id"],
|
db_cols = {c["name"] for c in inspector.get_columns(tbl_name)}
|
||||||
"user_achievements": ["user_id", "achievement_id"],
|
missing = model_cols - db_cols
|
||||||
}
|
if missing:
|
||||||
with engine.connect() as conn:
|
print(f"[DB] Migrating {tbl_name}: adding {missing}")
|
||||||
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:
|
except Exception as e:
|
||||||
print(f"[DB] Миграция индексов: {e}")
|
print(f"[DB] Migration check: {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
|
# ─── Session helpers ────────────────────────────────────────────────────────
|
||||||
try:
|
def get_db() -> Generator:
|
||||||
from sqlalchemy import text
|
|
||||||
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)
|
|
||||||
try:
|
|
||||||
inspector = inspect(engine)
|
|
||||||
cols = [c["name"] for c in inspector.get_columns("user_rpu")]
|
|
||||||
for col, coltype in [
|
|
||||||
("luck_budget", "FLOAT DEFAULT 100.0"),
|
|
||||||
("session_spent", "FLOAT DEFAULT 0.0"),
|
|
||||||
("session_won", "FLOAT DEFAULT 0.0"),
|
|
||||||
("session_reset_date", "TIMESTAMP"),
|
|
||||||
("ceiling_multiplier", "FLOAT DEFAULT 1.0"),
|
|
||||||
("ceiling_break_count", "INTEGER DEFAULT 0"),
|
|
||||||
("ceiling_break_session", "INTEGER DEFAULT 0"),
|
|
||||||
("consecutive_loss_value", "FLOAT DEFAULT 0.0"),
|
|
||||||
("consecutive_loss_count", "INTEGER DEFAULT 0"),
|
|
||||||
("comeback_active", "BOOLEAN DEFAULT 0"),
|
|
||||||
("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 если нет
|
|
||||||
try:
|
|
||||||
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)
|
||||||
|
|||||||
+75
-17
@@ -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,
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Сидируем достижения при старте
|
# Сидируем достижения при старте
|
||||||
|
try:
|
||||||
|
from database import init_sync_db
|
||||||
|
init_sync_db()
|
||||||
db_seed = next(get_db())
|
db_seed = next(get_db())
|
||||||
seed_achievements(db_seed)
|
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()
|
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():
|
||||||
"""Рассылает всем количество игроков онлайн"""
|
"""Рассылает всем количество игроков онлайн"""
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.3–1.0).
|
|
||||||
Чем ближе к потолку — тем меньше множитель.
|
|
||||||
Не блокирует открытие, а лишь делает удачу чуть хуже.
|
|
||||||
"""
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
ceiling = calculate_ceiling(db, user_id)
|
|
||||||
inv_value = get_total_inventory_value(db, user_id)
|
|
||||||
withdrawn = user.total_withdrawn or 0
|
|
||||||
extracted_value = inv_value + withdrawn
|
|
||||||
|
|
||||||
if ceiling <= 0:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
ratio = extracted_value / ceiling
|
|
||||||
|
|
||||||
if ratio < 0.8:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
if ratio >= 1.0:
|
|
||||||
return 0.3
|
|
||||||
|
|
||||||
return 1.0 - (ratio - 0.8) / 0.2 * 0.7
|
|
||||||
|
|
||||||
|
|
||||||
def check_ceiling_breach(db: Session, user_id: int, item_price: float,
|
|
||||||
case_price: float) -> bool:
|
|
||||||
"""
|
|
||||||
Проверяет, пробивает ли предмет потолок.
|
|
||||||
Если предмет дороже 50% от потолка — пробитие.
|
|
||||||
"""
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
|
|
||||||
ceiling = calculate_ceiling(db, user_id)
|
|
||||||
total_value = user.balance + get_total_inventory_value(db, user_id)
|
|
||||||
|
|
||||||
# Если инвентарь уже под потолком — не пробиваем
|
|
||||||
if total_value < ceiling:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Сам предмет должен быть дорогим
|
|
||||||
if item_price < ceiling * 0.5:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def apply_ceiling_breach(db: Session, user_id: int, item_price: float):
|
|
||||||
"""
|
|
||||||
Пробитие потолка: расширяем потолок на 50% на остаток сессии.
|
|
||||||
"""
|
|
||||||
rpu = get_user_rpu(db, user_id)
|
|
||||||
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5)
|
|
||||||
rpu.ceiling_break_count += 1
|
|
||||||
rpu.ceiling_break_session += 1
|
|
||||||
_rpu_commit_or_flush(db)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_ceiling_withdrawal_penalty(db: Session, user_id: int):
|
|
||||||
"""
|
|
||||||
При большом выводе — скручиваем потолок на следующих N сессий.
|
|
||||||
"""
|
|
||||||
rpu = get_user_rpu(db, user_id)
|
|
||||||
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
|
|
||||||
_rpu_commit_or_flush(db)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Luck Budget ──
|
|
||||||
|
|
||||||
# Флаг: если True — sub-функции делают flush вместо commit.
|
|
||||||
# Устанавливается в True на время горячего пути (case_open, upgrade).
|
|
||||||
_BATCH_MODE = False
|
_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)),
|
||||||
|
|||||||
@@ -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}"
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -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);}
|
||||||
Vendored
+19
@@ -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;}};})();
|
||||||
Vendored
+4
@@ -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());
|
||||||
Vendored
+4
@@ -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,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', () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,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() {
|
||||||
|
|||||||
@@ -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,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');
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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)
|
||||||
Reference in New Issue
Block a user