PostgreSQL + async refactoring, WebSocket server, Redis caching, minified assets
This commit is contained in:
@@ -5,3 +5,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.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 passlib.context import CryptContext
|
||||
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 database import get_db, User
|
||||
from database import get_async_db, get_db, User
|
||||
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"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Декодирует JWT токен"""
|
||||
if not token: # <-- Добавлена проверка
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
async def get_current_user_from_token(token: str, db: Session) -> Optional[User]:
|
||||
"""Получает пользователя из JWT токена"""
|
||||
if not token: # <-- Добавлена проверка
|
||||
|
||||
async def get_current_user_from_token(token: str, db) -> Optional[User]:
|
||||
if not token:
|
||||
return None
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
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")
|
||||
if not token: # <-- Исправлено: сразу возвращаем None
|
||||
if not token:
|
||||
return None
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:]
|
||||
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")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:]
|
||||
|
||||
user = await get_current_user_from_token(token, db)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
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:
|
||||
hashed_password = get_password_hash(password)
|
||||
user = User(username=username, hashed_password=hashed_password, balance=1000.0)
|
||||
hashed = get_password_hash(password)
|
||||
user = User(username=username, hashed_password=hashed, balance=1000.0)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
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)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
if not item.get("is_craftable", True):
|
||||
return False
|
||||
|
||||
if is_final_in_collection(item):
|
||||
return False
|
||||
|
||||
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
|
||||
+172
-216
@@ -1,27 +1,59 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text, text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Generator, Optional
|
||||
|
||||
DATABASE_URL = "sqlite:///./cs2_simulator.db"
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
from sqlalchemy import (
|
||||
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)
|
||||
Base = declarative_base()
|
||||
|
||||
# Включаем WAL mode для параллельных чтений и уменьшения SQLITE_BUSY
|
||||
with engine.connect() as conn:
|
||||
if is_sqlite:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
conn.execute(text("PRAGMA synchronous=NORMAL"))
|
||||
conn.execute(text("PRAGMA cache_size=-64000"))
|
||||
conn.execute(text("PRAGMA busy_timeout=5000"))
|
||||
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):
|
||||
__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)
|
||||
hashed_password = Column(String(200), nullable=False)
|
||||
balance = Column(Float, default=0.0)
|
||||
@@ -29,11 +61,9 @@ class User(Base):
|
||||
is_banned = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
# Карта (кэш, источник правды — Bank API)
|
||||
card_balance_cached = Column(Float, default=1000.0)
|
||||
total_deposited = Column(Float, default=0.0) # всего закинуто card→site
|
||||
total_withdrawn = Column(Float, default=0.0) # всего выведено site→card
|
||||
total_deposited = Column(Float, default=0.0)
|
||||
total_withdrawn = Column(Float, default=0.0)
|
||||
last_deposit_date = Column(DateTime, nullable=True)
|
||||
|
||||
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")
|
||||
transactions = relationship("TransactionLog", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class UpgradeRPU(Base):
|
||||
__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)
|
||||
|
||||
# Множитель шанса апгрейда (1.0 = стандартный)
|
||||
upgrade_multiplier = Column(Float, default=1.0)
|
||||
|
||||
# Автоматическая подкрутка
|
||||
auto_adjust = Column(Boolean, default=False)
|
||||
|
||||
# Статистика
|
||||
total_attempts = 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_lose_streak = Column(Integer, default=0)
|
||||
best_win_streak = Column(Integer, default=0)
|
||||
worst_lose_streak = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="upgrade_rpu")
|
||||
|
||||
|
||||
class Upgrade(Base):
|
||||
__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)
|
||||
input_item_id = Column(Integer, nullable=False)
|
||||
input_item_name = Column(String(200))
|
||||
@@ -85,19 +108,18 @@ class Upgrade(Base):
|
||||
target_item_name = Column(String(200))
|
||||
target_item_image_url = Column(String(500), default="")
|
||||
success = Column(Boolean, default=False)
|
||||
probability = Column(Float) # Базовый шанс
|
||||
rpu_adjusted_probability = Column(Float) # Шанс с учетом РПУ
|
||||
probability = Column(Float)
|
||||
rpu_adjusted_probability = Column(Float)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="upgrades")
|
||||
|
||||
|
||||
class UserRPU(Base):
|
||||
__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)
|
||||
|
||||
# Множители шансов для каждой редкости (1.0 = стандартный шанс)
|
||||
consumer_multiplier = Column(Float, default=1.0)
|
||||
industrial_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)
|
||||
covert_multiplier = Column(Float, default=1.0)
|
||||
rare_special_multiplier = Column(Float, default=1.0)
|
||||
|
||||
# Общий множитель удачи
|
||||
luck_multiplier = Column(Float, default=1.0)
|
||||
|
||||
# Автоматическая подкрутка
|
||||
auto_adjust = Column(Boolean, default=False)
|
||||
|
||||
# Статистика для авто-подкрутки
|
||||
total_spent = Column(Float, default=0.0)
|
||||
total_opened = Column(Integer, default=0)
|
||||
total_value_received = Column(Float, default=0.0)
|
||||
last_adjustment = Column(DateTime, nullable=True)
|
||||
|
||||
# Серии
|
||||
current_streak = Column(Integer, default=0)
|
||||
best_streak = Column(Integer, default=0)
|
||||
worst_streak = Column(Integer, default=0)
|
||||
|
||||
# Последние результаты открытий
|
||||
last_results = Column(String(500), default="")
|
||||
|
||||
# ── RPU v2: Session (сессии) ──
|
||||
luck_budget = Column(Float, default=100.0) # 0-100, остаток удачи на сессию
|
||||
session_spent = Column(Float, default=0.0) # потрачено в текущей сессии
|
||||
session_won = Column(Float, default=0.0) # выиграно (ценность) в сессии
|
||||
session_reset_date = Column(DateTime, nullable=True) # когда сброшена сессия
|
||||
|
||||
# ── RPU v2: Ceiling (потолок) ──
|
||||
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) # стоимость последовательных проигрышей
|
||||
luck_budget = Column(Float, default=100.0)
|
||||
session_spent = Column(Float, default=0.0)
|
||||
session_won = Column(Float, default=0.0)
|
||||
session_reset_date = Column(DateTime, nullable=True)
|
||||
ceiling_multiplier = Column(Float, default=1.0)
|
||||
ceiling_break_count = Column(Integer, default=0)
|
||||
ceiling_break_session = Column(Integer, default=0)
|
||||
consecutive_loss_value = Column(Float, default=0.0)
|
||||
consecutive_loss_count = Column(Integer, default=0)
|
||||
comeback_active = Column(Boolean, default=False)
|
||||
comeback_openings_left = Column(Integer, default=0)
|
||||
comeback_multiplier = Column(Float, default=1.0) # множитель удачи в режиме комбека
|
||||
|
||||
# ── RPU v2: Hot/Cold анализ ──
|
||||
hot_score = Column(Float, default=0.0) # >0 = горячий, <0 = холодный
|
||||
comeback_multiplier = Column(Float, default=1.0)
|
||||
hot_score = Column(Float, default=0.0)
|
||||
last_activity_date = Column(DateTime, nullable=True)
|
||||
|
||||
# Мета-данные
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="rpu_settings")
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
__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)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
reward_type = Column(String(30), nullable=False) # card_to_site, luck_boost, ceiling_boost, free_case, reset_streak
|
||||
id = Column(Integer, primary_key=True)
|
||||
code = Column(String(50), unique=True, nullable=False)
|
||||
reward_type = Column(String(30), nullable=False)
|
||||
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)
|
||||
used_count = Column(Integer, default=0)
|
||||
start_date = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -174,44 +176,57 @@ class PromoCode(Base):
|
||||
|
||||
class TransactionLog(Base):
|
||||
__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)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
tx_type = Column(String(30), nullable=False) # card_to_site, site_to_card, promo_bonus, admin
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
tx_type = Column(String(30), nullable=False)
|
||||
amount = Column(Float, default=0.0)
|
||||
fee = Column(Float, default=0.0)
|
||||
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)
|
||||
details = Column(String(500), default="")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="transactions")
|
||||
|
||||
|
||||
class InventoryItem(Base):
|
||||
__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)
|
||||
item_id = Column(Integer, nullable=False) # ID из ALL_ITEMS
|
||||
item_id = Column(Integer, nullable=False)
|
||||
market_hash_name = Column(String(200), nullable=False)
|
||||
rarity = Column(String(50))
|
||||
wear = Column(String(50))
|
||||
float_value = Column(Float, default=0.0)
|
||||
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)
|
||||
is_equipped = Column(Boolean, default=False)
|
||||
image_url = Column(String(500), default="")
|
||||
price_rub = Column(Float, default=0.0)
|
||||
|
||||
user = relationship("User", back_populates="inventory_items")
|
||||
|
||||
|
||||
class CaseOpening(Base):
|
||||
__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)
|
||||
case_name = Column(String(100), nullable=False)
|
||||
item_id = Column(Integer, nullable=False)
|
||||
@@ -219,197 +234,138 @@ class CaseOpening(Base):
|
||||
rarity = Column(String(50))
|
||||
float_value = Column(Float)
|
||||
opened_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="case_openings")
|
||||
|
||||
|
||||
class Contract(Base):
|
||||
__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)
|
||||
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_name = Column(String(200))
|
||||
output_float = Column(Float)
|
||||
probability = Column(Float)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="contracts")
|
||||
|
||||
|
||||
class Achievement(Base):
|
||||
__tablename__ = "achievements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(String(500))
|
||||
icon = Column(String(50), default="🏆")
|
||||
category = Column(String(50), default="general") # cases, contracts, upgrade, social, general
|
||||
requirement_type = Column(String(50), nullable=False) # open_cases, contracts, upgrade_wins, total_spent, valuable_item, etc.
|
||||
requirement_value = Column(Integer, nullable=False) # how many
|
||||
reward_amount = Column(Float, default=0.0) # bonus rubles
|
||||
category = Column(String(50), default="general")
|
||||
requirement_type = Column(String(50), nullable=False)
|
||||
requirement_value = Column(Integer, nullable=False)
|
||||
reward_amount = Column(Float, default=0.0)
|
||||
sort_order = Column(Integer, default=0)
|
||||
hidden = Column(Boolean, default=False) # секретные достижения
|
||||
|
||||
hidden = Column(Boolean, default=False)
|
||||
user_achievements = relationship("UserAchievement", back_populates="achievement")
|
||||
|
||||
|
||||
class UserAchievement(Base):
|
||||
__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)
|
||||
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)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", backref="user_achievements")
|
||||
achievement = relationship("Achievement", back_populates="user_achievements")
|
||||
|
||||
|
||||
class ActivityFeed(Base):
|
||||
__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)
|
||||
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)
|
||||
data_json = Column(Text, default="{}") # дополнительные данные (item_name, price, etc.)
|
||||
data_json = Column(Text, default="{}")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", backref="activities")
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Миграция: добавляем недостающие индексы для производительности
|
||||
try:
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(engine)
|
||||
indices_to_add = {
|
||||
"inventory_items": ["user_id"],
|
||||
"case_openings": ["user_id", "opened_at"],
|
||||
"upgrades": ["user_id"],
|
||||
"contracts": ["user_id"],
|
||||
"activity_feed": ["user_id"],
|
||||
"user_achievements": ["user_id", "achievement_id"],
|
||||
}
|
||||
with engine.connect() as conn:
|
||||
for tbl, cols in indices_to_add.items():
|
||||
existing = [ix["name"] for ix in inspector.get_indexes(tbl)]
|
||||
for col in cols:
|
||||
ix_name = f"ix_{tbl}_{col}"
|
||||
if ix_name not in existing:
|
||||
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {ix_name} ON {tbl} ({col})"))
|
||||
conn.commit()
|
||||
print("[DB] Индексы: OK")
|
||||
except Exception as e:
|
||||
print(f"[DB] Миграция индексов: {e}")
|
||||
# ─── Create tables ──────────────────────────────────────────────────────────
|
||||
def init_sync_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_run_migrations(engine, is_sync=True)
|
||||
|
||||
# Миграция: добавляем колонку 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
|
||||
try:
|
||||
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
|
||||
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:
|
||||
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}")
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn)
|
||||
existing_tables = inspector.get_table_names()
|
||||
for tbl_name in Base.metadata.tables:
|
||||
if tbl_name not in existing_tables:
|
||||
continue
|
||||
model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns}
|
||||
db_cols = {c["name"] for c in inspector.get_columns(tbl_name)}
|
||||
missing = model_cols - db_cols
|
||||
if missing:
|
||||
print(f"[DB] Migrating {tbl_name}: adding {missing}")
|
||||
except Exception as e:
|
||||
print(f"[DB] Migration check: {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():
|
||||
# ─── Session helpers ────────────────────────────────────────────────────────
|
||||
def get_db() -> Generator:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ─── Thread-safe sync session wrapper for async endpoints ──────────────────
|
||||
# Use this as a bridge: runs sync DB ops in executor to avoid blocking event loop.
|
||||
# The session is closed when the context exits.
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_executor():
|
||||
"""Async context manager wrapping sync SessionLocal in executor thread.
|
||||
Use for endpoints that haven't been fully async-converted yet."""
|
||||
loop = asyncio.get_running_loop()
|
||||
db = await loop.run_in_executor(None, lambda: SessionLocal())
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await loop.run_in_executor(None, db.close)
|
||||
|
||||
+77
-19
@@ -29,30 +29,29 @@ from pathlib import Path
|
||||
import json
|
||||
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 sqlalchemy import select, func as sa_func, desc
|
||||
from achievements import (
|
||||
seed_achievements, check_achievement,
|
||||
check_open_cases_achievements, check_contracts_achievements,
|
||||
check_upgrade_achievements, check_upgrade_risk_achievements, check_upgrade_result_achievements,
|
||||
check_rare_item_achievement, check_knife_achievement,
|
||||
check_spent_achievements,
|
||||
check_balance_achievements,
|
||||
check_slot_match_achievements, check_slot_plays_achievements,
|
||||
check_slot_match_count_achievements,
|
||||
check_inventory_achievements,
|
||||
check_dildo_achievements, check_dildo_contract_achievement,
|
||||
check_crash_achievements, check_crash_cashout_achievements,
|
||||
check_items_unboxed_achievements, check_total_profit_achievements,
|
||||
check_achievements_count_achievements,
|
||||
check_upgrade_achievements, check_upgrade_risk_achievements,
|
||||
check_upgrade_result_achievements, check_rare_item_achievement,
|
||||
check_knife_achievement, check_spent_achievements,
|
||||
check_balance_achievements, check_slot_match_achievements,
|
||||
check_slot_plays_achievements, check_slot_match_count_achievements,
|
||||
check_inventory_achievements, check_dildo_achievements,
|
||||
check_dildo_contract_achievement, check_crash_achievements,
|
||||
check_crash_cashout_achievements, check_items_unboxed_achievements,
|
||||
check_total_profit_achievements, check_achievements_count_achievements,
|
||||
check_rare_item_count_achievements, check_knife_count_achievements,
|
||||
check_midnight_case_achievement, check_exact_cases_achievement,
|
||||
check_collection_achievements,
|
||||
get_user_achievements
|
||||
check_collection_achievements, get_user_achievements
|
||||
)
|
||||
from auth import (
|
||||
create_user, authenticate_user, create_access_token,
|
||||
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
|
||||
)
|
||||
|
||||
@@ -70,9 +69,10 @@ from backend import (
|
||||
from upgrade import (
|
||||
get_upgrade_rpu, calculate_upgrade_probability,
|
||||
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 auth import get_password_hash
|
||||
@@ -100,6 +100,40 @@ from backend import app as api_app
|
||||
# Монтируем оригинальное API на /api/simulator чтобы избежать конфликтов
|
||||
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-предметов (создаётся один раз)
|
||||
@@ -243,9 +277,33 @@ def record_activity_own_session(user_id: int, username: str, activity_type: str,
|
||||
}
|
||||
|
||||
# Сидируем достижения при старте
|
||||
db_seed = next(get_db())
|
||||
seed_achievements(db_seed)
|
||||
db_seed.close()
|
||||
try:
|
||||
from database import init_sync_db
|
||||
init_sync_db()
|
||||
db_seed = next(get_db())
|
||||
from achievements import ACHIEVEMENT_DEFS
|
||||
from database import Achievement
|
||||
existing = db_seed.query(Achievement).count()
|
||||
if existing == 0:
|
||||
for defn in ACHIEVEMENT_DEFS:
|
||||
db_seed.add(Achievement(**defn))
|
||||
db_seed.commit()
|
||||
print(f"[Seed] Добавлено {len(ACHIEVEMENT_DEFS)} достижений")
|
||||
else:
|
||||
existing_names = {a.name for a in db_seed.query(Achievement.name).all()}
|
||||
added = 0
|
||||
for defn in ACHIEVEMENT_DEFS:
|
||||
if defn["name"] not in existing_names:
|
||||
db_seed.add(Achievement(**defn))
|
||||
added += 1
|
||||
if added:
|
||||
db_seed.commit()
|
||||
print(f"[Seed] Добавлено {added} новых достижений")
|
||||
db_seed.close()
|
||||
except Exception as e:
|
||||
print(f"[Seed] {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
async def broadcast_online_count():
|
||||
"""Рассылает всем количество игроков онлайн"""
|
||||
|
||||
@@ -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
|
||||
python-jose[cryptography]
|
||||
passlib
|
||||
bcrypt
|
||||
bcrypt>=4.0.0
|
||||
python-multipart
|
||||
python-dateutil
|
||||
aiofiles
|
||||
tqdm
|
||||
aiosqlite
|
||||
asyncpg
|
||||
redis>=5.0
|
||||
|
||||
@@ -3,21 +3,16 @@ import random
|
||||
import math
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database import User, UserRPU, InventoryItem
|
||||
from promo_phase import get_promo_effect_for_user, get_current_phase
|
||||
|
||||
# Стандартные веса редкостей
|
||||
BASE_RARITY_WEIGHTS = {
|
||||
"Consumer Grade": 10000,
|
||||
"Industrial Grade": 5000,
|
||||
"Mil-Spec": 2500,
|
||||
"Mil-Spec Grade": 2500,
|
||||
"Restricted": 625,
|
||||
"Classified": 125,
|
||||
"Covert": 25,
|
||||
"Rare Special Item": 5,
|
||||
"Extraordinary": 5
|
||||
"Consumer Grade": 10000, "Industrial Grade": 5000,
|
||||
"Mil-Spec": 2500, "Mil-Spec Grade": 2500,
|
||||
"Restricted": 625, "Classified": 125,
|
||||
"Covert": 25, "Rare Special Item": 5, "Extraordinary": 5
|
||||
}
|
||||
|
||||
RARITY_TO_FIELD = {
|
||||
@@ -33,421 +28,321 @@ RARITY_TO_FIELD = {
|
||||
}
|
||||
|
||||
RARITY_RANK = {
|
||||
"Consumer Grade": 0,
|
||||
"Industrial Grade": 1,
|
||||
"Mil-Spec": 2,
|
||||
"Mil-Spec Grade": 2,
|
||||
"Restricted": 3,
|
||||
"Classified": 4,
|
||||
"Covert": 5,
|
||||
"Rare Special Item": 6,
|
||||
"Extraordinary": 6
|
||||
"Consumer Grade": 0, "Industrial Grade": 1,
|
||||
"Mil-Spec": 2, "Mil-Spec Grade": 2,
|
||||
"Restricted": 3, "Classified": 4, "Covert": 5,
|
||||
"Rare Special Item": 6, "Extraordinary": 6
|
||||
}
|
||||
|
||||
# Пороги потолка
|
||||
CEILING_BASE_MULTIPLIER = 3.0
|
||||
CEILING_MIN = 1000.0
|
||||
CEILING_SESSION_HISTORY = {
|
||||
0: 5.0, # первая сессия
|
||||
1: 4.0, # вторая
|
||||
2: 3.5, # третья
|
||||
-1: 3.0 # 3+ сессий
|
||||
}
|
||||
CEILING_SESSION_HISTORY = {0: 5.0, 1: 4.0, 2: 3.5, -1: 3.0}
|
||||
|
||||
# Пороги для luck_budget
|
||||
BUDGET_MAX = 100.0
|
||||
BUDGET_CONSUMPTION_RATE = 0.001 # 0.1% от цены предмета
|
||||
BUDGET_REGEN_PER_OPEN = 0.5 # +0.5% за каждое открытие
|
||||
BUDGET_LOW_THRESHOLD = 20.0 # если budget < 20%, удача падает
|
||||
BUDGET_CONSUMPTION_RATE = 0.001
|
||||
BUDGET_REGEN_PER_OPEN = 0.5
|
||||
BUDGET_LOW_THRESHOLD = 20.0
|
||||
|
||||
# Комбек
|
||||
COMEBACK_TRIGGER_COUNT = 5 # 5+ проигрышей подряд → комбек
|
||||
COMEBACK_TRIGGER_VALUE_RATIO = 0.3 # потеря >30% от total_deposited
|
||||
COMEBACK_OPENINGS = 5 # комбек на 5 открытий
|
||||
COMEBACK_MULTIPLIER_MAX = 3.0 # макс множитель удачи в комбеке
|
||||
COMEBACK_TRIGGER_COUNT = 5
|
||||
COMEBACK_TRIGGER_VALUE_RATIO = 0.3
|
||||
COMEBACK_OPENINGS = 5
|
||||
COMEBACK_MULTIPLIER_MAX = 3.0
|
||||
|
||||
# Hot/Cold
|
||||
HOT_THRESHOLD_SPENT = 10000 # потратил >10k
|
||||
HOT_THRESHOLD_WITHDRAWAL = 0.1 # вывел <10% от депозитов
|
||||
COLD_DAYS_INACTIVE = 7 # 7 дней неактивен = холодный
|
||||
COLD_SPENT_PER_DAY = 100 # <100₽/день = холодный
|
||||
HOT_THRESHOLD_SPENT = 10000
|
||||
HOT_THRESHOLD_WITHDRAWAL = 0.1
|
||||
COLD_DAYS_INACTIVE = 7
|
||||
COLD_SPENT_PER_DAY = 100
|
||||
|
||||
|
||||
def get_user_rpu(db: Session, user_id: int) -> UserRPU:
|
||||
rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first()
|
||||
if not rpu:
|
||||
rpu = UserRPU(user_id=user_id)
|
||||
db.add(rpu)
|
||||
_rpu_commit_or_flush(db)
|
||||
db.refresh(rpu)
|
||||
return rpu
|
||||
|
||||
|
||||
# ── Session Management ──
|
||||
|
||||
def check_and_reset_session(db: Session, user_id: int):
|
||||
"""
|
||||
Проверяет, не пора ли сбросить сессию (каждый календарный день).
|
||||
"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
today = date.today()
|
||||
|
||||
if rpu.session_reset_date is None:
|
||||
rpu.session_reset_date = datetime.utcnow()
|
||||
rpu.luck_budget = BUDGET_MAX
|
||||
rpu.session_spent = 0
|
||||
rpu.session_won = 0
|
||||
rpu.ceiling_break_session = 0
|
||||
rpu.comeback_active = False
|
||||
rpu.comeback_openings_left = 0
|
||||
rpu.comeback_multiplier = 1.0
|
||||
rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
_rpu_commit_or_flush(db)
|
||||
return
|
||||
|
||||
last_reset = rpu.session_reset_date.date()
|
||||
if last_reset < today:
|
||||
rpu.session_reset_date = datetime.utcnow()
|
||||
rpu.luck_budget = BUDGET_MAX
|
||||
rpu.session_spent = 0
|
||||
rpu.session_won = 0
|
||||
rpu.ceiling_break_session = 0
|
||||
rpu.comeback_active = False
|
||||
rpu.comeback_openings_left = 0
|
||||
rpu.comeback_multiplier = 1.0
|
||||
rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
_rpu_commit_or_flush(db)
|
||||
|
||||
|
||||
# ── Ceiling (Потолок) ──
|
||||
|
||||
def calculate_ceiling(db: Session, user_id: int) -> float:
|
||||
"""
|
||||
Потолок = total_deposited * mult.
|
||||
mult зависит от количества сессий на сайте.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return CEILING_MIN
|
||||
|
||||
total_dep = max(0.0, user.total_deposited)
|
||||
|
||||
# Считаем, сколько прошло сессий (дней с первой транзакции)
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
session_count = 0
|
||||
if rpu.session_reset_date and user.created_at:
|
||||
days_since = (datetime.utcnow() - user.created_at).days
|
||||
session_count = max(0, days_since)
|
||||
|
||||
if session_count == 0:
|
||||
mult = CEILING_SESSION_HISTORY[0]
|
||||
elif session_count == 1:
|
||||
mult = CEILING_SESSION_HISTORY[1]
|
||||
elif session_count == 2:
|
||||
mult = CEILING_SESSION_HISTORY[2]
|
||||
else:
|
||||
mult = CEILING_SESSION_HISTORY[-1]
|
||||
|
||||
# Учитываем ceiling_multiplier (временные модификаторы)
|
||||
mult *= rpu.ceiling_multiplier
|
||||
|
||||
ceiling = total_dep * mult
|
||||
return max(CEILING_MIN, ceiling)
|
||||
|
||||
|
||||
def get_total_inventory_value(db: Session, user_id: int) -> float:
|
||||
"""Суммарная стоимость инвентаря пользователя"""
|
||||
items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all()
|
||||
return sum(item.price_rub or 0 for item in items)
|
||||
|
||||
|
||||
def get_user_total_value(db: Session, user_id: int) -> float:
|
||||
"""Баланс + инвентарь"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
balance = user.balance if user else 0
|
||||
inv = get_total_inventory_value(db, user_id)
|
||||
return balance + inv
|
||||
|
||||
|
||||
def get_ceiling_luck_factor(db: Session, user_id: int) -> float:
|
||||
"""
|
||||
Возвращает множитель удачи на основе близости к потолку (0.3–1.0).
|
||||
Чем ближе к потолку — тем меньше множитель.
|
||||
Не блокирует открытие, а лишь делает удачу чуть хуже.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return 1.0
|
||||
|
||||
ceiling = calculate_ceiling(db, user_id)
|
||||
inv_value = get_total_inventory_value(db, user_id)
|
||||
withdrawn = user.total_withdrawn or 0
|
||||
extracted_value = inv_value + withdrawn
|
||||
|
||||
if ceiling <= 0:
|
||||
return 1.0
|
||||
|
||||
ratio = extracted_value / ceiling
|
||||
|
||||
if ratio < 0.8:
|
||||
return 1.0
|
||||
|
||||
if ratio >= 1.0:
|
||||
return 0.3
|
||||
|
||||
return 1.0 - (ratio - 0.8) / 0.2 * 0.7
|
||||
|
||||
|
||||
def check_ceiling_breach(db: Session, user_id: int, item_price: float,
|
||||
case_price: float) -> bool:
|
||||
"""
|
||||
Проверяет, пробивает ли предмет потолок.
|
||||
Если предмет дороже 50% от потолка — пробитие.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
ceiling = calculate_ceiling(db, user_id)
|
||||
total_value = user.balance + get_total_inventory_value(db, user_id)
|
||||
|
||||
# Если инвентарь уже под потолком — не пробиваем
|
||||
if total_value < ceiling:
|
||||
return False
|
||||
|
||||
# Сам предмет должен быть дорогим
|
||||
if item_price < ceiling * 0.5:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def apply_ceiling_breach(db: Session, user_id: int, item_price: float):
|
||||
"""
|
||||
Пробитие потолка: расширяем потолок на 50% на остаток сессии.
|
||||
"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5)
|
||||
rpu.ceiling_break_count += 1
|
||||
rpu.ceiling_break_session += 1
|
||||
_rpu_commit_or_flush(db)
|
||||
|
||||
|
||||
def apply_ceiling_withdrawal_penalty(db: Session, user_id: int):
|
||||
"""
|
||||
При большом выводе — скручиваем потолок на следующих N сессий.
|
||||
"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
|
||||
_rpu_commit_or_flush(db)
|
||||
|
||||
|
||||
# ── Luck Budget ──
|
||||
|
||||
# Флаг: если True — sub-функции делают flush вместо commit.
|
||||
# Устанавливается в True на время горячего пути (case_open, upgrade).
|
||||
_BATCH_MODE = False
|
||||
|
||||
# Кэш adjusted_weights на время batch-операции
|
||||
# Ключ: user_id, значение: результат get_adjusted_weights
|
||||
_BATCH_WEIGHTS_CACHE = {}
|
||||
|
||||
|
||||
def _rpu_commit_or_flush(db):
|
||||
if _BATCH_MODE:
|
||||
db.flush()
|
||||
else:
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _rpu_commit_or_flush_async(db: AsyncSession):
|
||||
if _BATCH_MODE:
|
||||
await db.flush()
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _batch_cache_get(user_id: int):
|
||||
"""Возвращает кэшированные adjusted_weights для batch или None."""
|
||||
if _BATCH_MODE and user_id in _BATCH_WEIGHTS_CACHE:
|
||||
return _BATCH_WEIGHTS_CACHE[user_id]
|
||||
return None
|
||||
|
||||
|
||||
def _batch_cache_set(user_id: int, weights: dict):
|
||||
"""Сохраняет adjusted_weights в кэш batch."""
|
||||
if _BATCH_MODE:
|
||||
_BATCH_WEIGHTS_CACHE[user_id] = weights
|
||||
|
||||
|
||||
def _batch_cache_clear(user_id: int = None):
|
||||
"""Очищает кэш batch (по user_id или полностью)."""
|
||||
if user_id is not None:
|
||||
_BATCH_WEIGHTS_CACHE.pop(user_id, None)
|
||||
else:
|
||||
_BATCH_WEIGHTS_CACHE.clear()
|
||||
|
||||
def consume_luck_budget(db: Session, user_id: int, item_price: float):
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
consumption = item_price * BUDGET_CONSUMPTION_RATE
|
||||
rpu.luck_budget = max(0.0, rpu.luck_budget - consumption)
|
||||
_rpu_commit_or_flush(db)
|
||||
|
||||
async def get_user_rpu(db: AsyncSession, user_id: int) -> UserRPU:
|
||||
result = await db.execute(select(UserRPU).where(UserRPU.user_id == user_id))
|
||||
rpu = result.scalar_one_or_none()
|
||||
if not rpu:
|
||||
rpu = UserRPU(user_id=user_id)
|
||||
db.add(rpu)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
await db.refresh(rpu)
|
||||
return rpu
|
||||
|
||||
|
||||
def regen_luck_budget(db: Session, user_id: int):
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
async def check_and_reset_session(db: AsyncSession, user_id: int):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
today = date.today()
|
||||
if rpu.session_reset_date is None:
|
||||
rpu.session_reset_date = datetime.utcnow()
|
||||
rpu.luck_budget = BUDGET_MAX
|
||||
rpu.session_spent = 0; rpu.session_won = 0
|
||||
rpu.ceiling_break_session = 0
|
||||
rpu.comeback_active = False; rpu.comeback_openings_left = 0
|
||||
rpu.comeback_multiplier = 1.0; rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
return
|
||||
last_reset = rpu.session_reset_date.date()
|
||||
if last_reset < today:
|
||||
rpu.session_reset_date = datetime.utcnow()
|
||||
rpu.luck_budget = BUDGET_MAX
|
||||
rpu.session_spent = 0; rpu.session_won = 0
|
||||
rpu.ceiling_break_session = 0
|
||||
rpu.comeback_active = False; rpu.comeback_openings_left = 0
|
||||
rpu.comeback_multiplier = 1.0; rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def calculate_ceiling(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return CEILING_MIN
|
||||
total_dep = max(0.0, user.total_deposited)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
session_count = 0
|
||||
if rpu.session_reset_date and user.created_at:
|
||||
days_since = (datetime.utcnow() - user.created_at).days
|
||||
session_count = max(0, days_since)
|
||||
mult = CEILING_SESSION_HISTORY.get(session_count, CEILING_SESSION_HISTORY[-1])
|
||||
mult *= rpu.ceiling_multiplier
|
||||
ceiling = total_dep * mult
|
||||
return max(CEILING_MIN, ceiling)
|
||||
|
||||
|
||||
async def get_total_inventory_value(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(InventoryItem.price_rub), 0))
|
||||
.where(InventoryItem.user_id == user_id)
|
||||
)
|
||||
return float(result.scalar() or 0)
|
||||
|
||||
|
||||
async def get_user_total_value(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
balance = user.balance if user else 0
|
||||
inv = await get_total_inventory_value(db, user_id)
|
||||
return balance + inv
|
||||
|
||||
|
||||
async def get_ceiling_luck_factor(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return 1.0
|
||||
ceiling = await calculate_ceiling(db, user_id)
|
||||
inv_value = await get_total_inventory_value(db, user_id)
|
||||
withdrawn = user.total_withdrawn or 0
|
||||
extracted_value = inv_value + withdrawn
|
||||
if ceiling <= 0:
|
||||
return 1.0
|
||||
ratio = extracted_value / ceiling
|
||||
if ratio < 0.8:
|
||||
return 1.0
|
||||
if ratio >= 1.0:
|
||||
return 0.3
|
||||
return 1.0 - (ratio - 0.8) / 0.2 * 0.7
|
||||
|
||||
|
||||
async def check_ceiling_breach(db: AsyncSession, user_id: int, item_price: float, case_price: float) -> bool:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
ceiling = await calculate_ceiling(db, user_id)
|
||||
total_value = user.balance + await get_total_inventory_value(db, user_id)
|
||||
if total_value < ceiling:
|
||||
return False
|
||||
if item_price < ceiling * 0.5:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def apply_ceiling_breach(db: AsyncSession, user_id: int, item_price: float):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5)
|
||||
rpu.ceiling_break_count += 1
|
||||
rpu.ceiling_break_session += 1
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def apply_ceiling_withdrawal_penalty(db: AsyncSession, user_id: int):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def consume_luck_budget(db: AsyncSession, user_id: int, item_price: float):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.luck_budget = max(0.0, rpu.luck_budget - item_price * BUDGET_CONSUMPTION_RATE)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def regen_luck_budget(db: AsyncSession, user_id: int):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.luck_budget = min(BUDGET_MAX, rpu.luck_budget + BUDGET_REGEN_PER_OPEN)
|
||||
_rpu_commit_or_flush(db)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
def get_budget_factor(rpu: UserRPU) -> float:
|
||||
"""
|
||||
Возвращает множитель на основе luck_budget.
|
||||
Если budget низкий (<20%) — удача падает.
|
||||
"""
|
||||
if rpu.luck_budget >= BUDGET_LOW_THRESHOLD:
|
||||
return 1.0
|
||||
return max(0.3, rpu.luck_budget / BUDGET_LOW_THRESHOLD)
|
||||
|
||||
|
||||
# ── Comeback System ──
|
||||
|
||||
def update_comeback_tracking(db: Session, user_id: int, spent: float,
|
||||
received_value: float):
|
||||
"""
|
||||
Отслеживает проигрыши для триггера комбека.
|
||||
"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
async def update_comeback_tracking(db: AsyncSession, user_id: int, spent: float, received_value: float):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return
|
||||
|
||||
# Если проиграл (получил меньше чем потратил)
|
||||
if received_value < spent * 0.8:
|
||||
rpu.consecutive_loss_count += 1
|
||||
rpu.consecutive_loss_value += (spent - received_value)
|
||||
else:
|
||||
rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
|
||||
# Проверяем триггер комбека
|
||||
total_dep = max(1.0, user.total_deposited)
|
||||
loss_ratio = rpu.consecutive_loss_value / total_dep
|
||||
|
||||
if (rpu.consecutive_loss_count >= COMEBACK_TRIGGER_COUNT or
|
||||
loss_ratio >= COMEBACK_TRIGGER_VALUE_RATIO):
|
||||
if not rpu.comeback_active:
|
||||
rpu.comeback_active = True
|
||||
rpu.comeback_openings_left = COMEBACK_OPENINGS
|
||||
rpu.comeback_multiplier = COMEBACK_MULTIPLIER_MAX
|
||||
|
||||
_rpu_commit_or_flush(db)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
def apply_comeback(db: Session, user_id: int) -> float:
|
||||
"""
|
||||
Применяет комбек-множитель если активен.
|
||||
Возвращает множитель для следующего открытия.
|
||||
"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
async def apply_comeback(db: AsyncSession, user_id: int) -> float:
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
if not rpu.comeback_active or rpu.comeback_openings_left <= 0:
|
||||
return 1.0
|
||||
|
||||
mult = rpu.comeback_multiplier
|
||||
rpu.comeback_openings_left -= 1
|
||||
|
||||
if rpu.comeback_openings_left <= 0:
|
||||
rpu.comeback_active = False
|
||||
rpu.comeback_multiplier = 1.0
|
||||
rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
|
||||
_rpu_commit_or_flush(db)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
return mult
|
||||
|
||||
|
||||
# ── Hot/Cold Analysis ──
|
||||
|
||||
def analyze_hot_cold(db: Session, user_id: int) -> Tuple[bool, bool, float]:
|
||||
"""
|
||||
Анализирует пользователя: горячий / холодный.
|
||||
Возвращает (is_hot, is_cold, withdrawal_ratio).
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
async def analyze_hot_cold(db: AsyncSession, user_id: int) -> Tuple[bool, bool, float]:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return False, True, 0.0
|
||||
|
||||
total_dep = max(0.0, user.total_deposited)
|
||||
total_wd = max(0.0, user.total_withdrawn)
|
||||
wd_ratio = total_wd / max(1.0, total_dep)
|
||||
|
||||
# Горячий: много потратил, почти не выводил
|
||||
is_hot = (total_dep >= HOT_THRESHOLD_SPENT and wd_ratio < HOT_THRESHOLD_WITHDRAWAL)
|
||||
|
||||
# Холодный: давно не был или мало активность
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
is_cold = False
|
||||
if rpu.last_activity_date:
|
||||
days_since = (datetime.utcnow() - rpu.last_activity_date).days
|
||||
if days_since >= COLD_DAYS_INACTIVE:
|
||||
is_cold = True
|
||||
else:
|
||||
is_cold = True # Новичок без активности
|
||||
|
||||
# Считаем hot_score
|
||||
is_cold = True
|
||||
days_active = max(1, (datetime.utcnow() - user.created_at).days)
|
||||
spent_per_day = total_dep / days_active
|
||||
if spent_per_day < COLD_SPENT_PER_DAY and total_dep > 0:
|
||||
is_cold = True
|
||||
|
||||
hot_score = total_dep - (total_wd * 2)
|
||||
rpu.hot_score = hot_score
|
||||
rpu.last_activity_date = datetime.utcnow()
|
||||
_rpu_commit_or_flush(db)
|
||||
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
return is_hot, is_cold, wd_ratio
|
||||
|
||||
|
||||
# ── Withdrawal Tax (Открутка при выводе) ──
|
||||
|
||||
def check_withdrawal_penalty(db: Session, user_id: int):
|
||||
"""
|
||||
Если пользователь вывел >50% от депозитов — скручиваем удачу и потолок.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
async def check_withdrawal_penalty(db: AsyncSession, user_id: int):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return
|
||||
|
||||
total_dep = max(1.0, user.total_deposited)
|
||||
total_wd = max(0.0, user.total_withdrawn)
|
||||
wd_ratio = total_wd / total_dep
|
||||
|
||||
if wd_ratio > 0.5:
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - wd_ratio * 0.3)
|
||||
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
|
||||
_rpu_commit_or_flush(db)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
# ── V2: Enhanced get_adjusted_weights ──
|
||||
def calculate_rpu_level(rpu: UserRPU) -> float:
|
||||
multipliers = [
|
||||
rpu.consumer_multiplier, rpu.industrial_multiplier,
|
||||
rpu.mil_spec_multiplier, rpu.restricted_multiplier,
|
||||
rpu.classified_multiplier, rpu.covert_multiplier,
|
||||
rpu.rare_special_multiplier
|
||||
]
|
||||
avg = sum(multipliers) / len(multipliers)
|
||||
if avg >= 1.0:
|
||||
level = 50 - ((avg - 1.0) / 2.0) * 100
|
||||
else:
|
||||
level = 50 + ((1.0 - avg) / 0.9) * 50
|
||||
return max(0, min(100, level))
|
||||
|
||||
def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
|
||||
"""
|
||||
Получает веса редкостей с учетом РПУ v2:
|
||||
- сессии, потолок, luck_budget, комбек, промо-фаза, hot/cold
|
||||
|
||||
В batch-режиме результат кэшируется — повторные вызовы с тем же user_id
|
||||
возвращают закешированное значение без DB-запросов.
|
||||
"""
|
||||
def get_rpu_description(level: float) -> str:
|
||||
if level < 20: return "🍀 Очень везучий"
|
||||
elif level < 35: return "😊 Везучий"
|
||||
elif level < 45: return "🙂 Немного везучий"
|
||||
elif level < 55: return "😐 Обычный"
|
||||
elif level < 70: return "🙁 Немного невезучий"
|
||||
elif level < 85: return "😟 Невезучий"
|
||||
else: return "💀 Проклятый"
|
||||
|
||||
|
||||
async def get_adjusted_weights(db: AsyncSession, user_id: int) -> Dict[str, float]:
|
||||
cached = _batch_cache_get(user_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
check_and_reset_session(db, user_id)
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
await check_and_reset_session(db, user_id)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
level = calculate_rpu_level(rpu)
|
||||
is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id)
|
||||
|
||||
# Промо-фаза
|
||||
promo = get_promo_effect_for_user(user_id, rpu.hot_score, wd_ratio, is_cold)
|
||||
|
||||
# Комбек
|
||||
comeback_mult = apply_comeback(db, user_id)
|
||||
|
||||
# Budget factor
|
||||
comeback_mult = await apply_comeback(db, user_id)
|
||||
budget_factor = get_budget_factor(rpu)
|
||||
|
||||
# Ceiling factor — не блокирует, а лишь приглушает удачу
|
||||
ceiling_factor = get_ceiling_luck_factor(db, user_id)
|
||||
ceiling_factor = await get_ceiling_luck_factor(db, user_id)
|
||||
|
||||
adjusted = {}
|
||||
for rarity, base_weight in BASE_RARITY_WEIGHTS.items():
|
||||
@@ -455,29 +350,18 @@ def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
|
||||
base_multiplier = getattr(rpu, field, 1.0) if field else 1.0
|
||||
rarity_index = RARITY_RANK.get(rarity, 0)
|
||||
|
||||
if rpu_level < 50:
|
||||
luck_factor = 1.0 + ((50 - rpu_level) / 50) * rarity_index * 0.3
|
||||
if level < 50:
|
||||
luck_factor = 1.0 + ((50 - level) / 50) * rarity_index * 0.3
|
||||
else:
|
||||
luck_factor = 1.0 - ((rpu_level - 50) / 50) * rarity_index * 0.2
|
||||
|
||||
luck_factor = 1.0 - ((level - 50) / 50) * rarity_index * 0.2
|
||||
luck_factor = max(0.1, min(3.0, luck_factor))
|
||||
|
||||
# Применяем все множители
|
||||
final = base_multiplier * rpu.luck_multiplier * luck_factor
|
||||
|
||||
# Промо-фаза
|
||||
final *= (1.0 + promo["luck_modifier"])
|
||||
|
||||
# Budget
|
||||
final *= budget_factor
|
||||
|
||||
# Комбек
|
||||
final *= comeback_mult
|
||||
|
||||
# Потолок — не блокирует, а лишь приглушает удачу
|
||||
final *= ceiling_factor
|
||||
|
||||
# Редкие предметы получают доп модификатор от промо
|
||||
if rarity_index >= 3:
|
||||
final *= (1.0 + promo.get("rare_chance_modifier", 0))
|
||||
|
||||
@@ -488,86 +372,40 @@ def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
|
||||
return adjusted
|
||||
|
||||
|
||||
def calculate_rpu_level(rpu: UserRPU) -> float:
|
||||
multipliers = [
|
||||
rpu.consumer_multiplier,
|
||||
rpu.industrial_multiplier,
|
||||
rpu.mil_spec_multiplier,
|
||||
rpu.restricted_multiplier,
|
||||
rpu.classified_multiplier,
|
||||
rpu.covert_multiplier,
|
||||
rpu.rare_special_multiplier
|
||||
]
|
||||
|
||||
avg = sum(multipliers) / len(multipliers)
|
||||
|
||||
if avg >= 1.0:
|
||||
level = 50 - ((avg - 1.0) / 2.0) * 100
|
||||
else:
|
||||
level = 50 + ((1.0 - avg) / 0.9) * 50
|
||||
|
||||
return max(0, min(100, level))
|
||||
|
||||
|
||||
def get_rpu_description(level: float) -> str:
|
||||
if level < 20:
|
||||
return "🍀 Очень везучий"
|
||||
elif level < 35:
|
||||
return "😊 Везучий"
|
||||
elif level < 45:
|
||||
return "🙂 Немного везучий"
|
||||
elif level < 55:
|
||||
return "😐 Обычный"
|
||||
elif level < 70:
|
||||
return "🙁 Немного невезучий"
|
||||
elif level < 85:
|
||||
return "😟 Невезучий"
|
||||
else:
|
||||
return "💀 Проклятый"
|
||||
|
||||
|
||||
def auto_adjust_rpu(db: Session, user_id: int):
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
async def auto_adjust_rpu(db: AsyncSession, user_id: int):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
if not rpu.auto_adjust:
|
||||
return
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return
|
||||
|
||||
if rpu.total_opened > 0 and rpu.total_spent > 0:
|
||||
roi = rpu.total_value_received / rpu.total_spent
|
||||
|
||||
if roi < 0.4:
|
||||
boost = (0.4 - roi) * 2
|
||||
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
|
||||
for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
||||
v = getattr(rpu, r)
|
||||
setattr(rpu, r, min(2.0, v + boost * 0.3))
|
||||
|
||||
setattr(rpu, r, min(2.0, getattr(rpu, r) + boost * 0.3))
|
||||
elif roi > 2.0:
|
||||
penalty = (roi - 2.0) * 0.5
|
||||
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty)
|
||||
for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
||||
v = getattr(rpu, r)
|
||||
setattr(rpu, r, max(0.3, v - penalty * 0.5))
|
||||
setattr(rpu, r, max(0.3, getattr(rpu, r) - penalty * 0.5))
|
||||
|
||||
streak = rpu.current_streak
|
||||
|
||||
if streak <= -5:
|
||||
boost = min(0.3, abs(streak) * 0.03)
|
||||
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
|
||||
rpu.classified_multiplier = min(2.0, rpu.classified_multiplier + boost * 0.5)
|
||||
rpu.covert_multiplier = min(1.8, rpu.covert_multiplier + boost * 0.4)
|
||||
rpu.rare_special_multiplier = min(1.5, rpu.rare_special_multiplier + boost * 0.3)
|
||||
|
||||
if streak >= 5:
|
||||
penalty = min(0.2, streak * 0.02)
|
||||
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty)
|
||||
for r in ['classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
||||
v = getattr(rpu, r)
|
||||
setattr(rpu, r, max(0.2, v - penalty * 0.5))
|
||||
|
||||
setattr(rpu, r, max(0.2, getattr(rpu, r) - penalty * 0.5))
|
||||
if rpu.total_opened > 50:
|
||||
rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05)
|
||||
rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03)
|
||||
@@ -575,19 +413,17 @@ def auto_adjust_rpu(db: Session, user_id: int):
|
||||
for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier',
|
||||
'restricted_multiplier', 'classified_multiplier', 'covert_multiplier',
|
||||
'rare_special_multiplier']:
|
||||
value = getattr(rpu, field)
|
||||
setattr(rpu, field, max(0.1, min(3.0, value)))
|
||||
|
||||
setattr(rpu, field, max(0.1, min(3.0, getattr(rpu, field))))
|
||||
rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier))
|
||||
rpu.last_adjustment = datetime.utcnow()
|
||||
_rpu_commit_or_flush(db)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1,
|
||||
received_value: float = 0.0, rarity: str = None,
|
||||
is_rare: bool = False):
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
async def update_rpu_stats(db: AsyncSession, user_id: int, spent: float, opened: int = 1,
|
||||
received_value: float = 0.0, rarity: str = None, is_rare: bool = False):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
rpu.total_spent += spent
|
||||
rpu.total_opened += opened
|
||||
@@ -605,45 +441,35 @@ def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1,
|
||||
if rpu.current_streak < rpu.worst_streak:
|
||||
rpu.worst_streak = rpu.current_streak
|
||||
|
||||
# ── RPU v2: Luck Budget ──
|
||||
consume_luck_budget(db, user_id, received_value)
|
||||
regen_luck_budget(db, user_id)
|
||||
await consume_luck_budget(db, user_id, received_value)
|
||||
await regen_luck_budget(db, user_id)
|
||||
await update_comeback_tracking(db, user_id, spent, received_value)
|
||||
|
||||
# ── RPU v2: Comeback tracking ──
|
||||
update_comeback_tracking(db, user_id, spent, received_value)
|
||||
|
||||
# ── RPU v2: Ceiling breach check ──
|
||||
if user and is_rare and received_value > 0:
|
||||
if check_ceiling_breach(db, user_id, received_value, spent):
|
||||
apply_ceiling_breach(db, user_id, received_value)
|
||||
if await check_ceiling_breach(db, user_id, received_value, spent):
|
||||
await apply_ceiling_breach(db, user_id, received_value)
|
||||
|
||||
# ── RPU v2: Hot/Cold ──
|
||||
analyze_hot_cold(db, user_id)
|
||||
|
||||
_rpu_commit_or_flush(db)
|
||||
await analyze_hot_cold(db, user_id)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
if rpu.auto_adjust:
|
||||
auto_adjust_rpu(db, user_id)
|
||||
await auto_adjust_rpu(db, user_id)
|
||||
|
||||
|
||||
def get_rpu_v2_info(db: Session, user_id: int) -> dict:
|
||||
"""
|
||||
Возвращает полную информацию о RPU v2 для профиля/админки.
|
||||
"""
|
||||
check_and_reset_session(db, user_id)
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
async def get_rpu_v2_info(db: AsyncSession, user_id: int) -> dict:
|
||||
await check_and_reset_session(db, user_id)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
ceiling = calculate_ceiling(db, user_id)
|
||||
inv_value = get_total_inventory_value(db, user_id)
|
||||
ceiling = await calculate_ceiling(db, user_id)
|
||||
inv_value = await get_total_inventory_value(db, user_id)
|
||||
total_value = user.balance + inv_value
|
||||
is_hot, is_cold, wd_ratio = analyze_hot_cold(db, user_id)
|
||||
is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id)
|
||||
|
||||
session_count = 0
|
||||
if user.created_at:
|
||||
session_count = max(0, (datetime.utcnow() - user.created_at).days)
|
||||
session_count = max(0, (datetime.utcnow() - user.created_at).days) if user.created_at else 0
|
||||
|
||||
return {
|
||||
"rpu_level": round(calculate_rpu_level(rpu)),
|
||||
|
||||
@@ -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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Достижения - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
<style>
|
||||
.achievement-card.secret {
|
||||
border-color: #8b5cf6;
|
||||
@@ -283,9 +283,9 @@
|
||||
<div class="popup-reward" id="popupReward"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 15px; }
|
||||
@@ -470,7 +470,7 @@
|
||||
</div>
|
||||
|
||||
{% block modals %}{% endblock %}
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const sidebar = document.getElementById('adminSidebar');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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">
|
||||
<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>
|
||||
/* Стили для карточек (только для фарм-кейсов) */
|
||||
.cards-grid {
|
||||
@@ -1875,11 +1875,11 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.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>
|
||||
function handleAchievements(data) {
|
||||
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Кейсы - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
<style>
|
||||
.cases-sections {
|
||||
display: flex;
|
||||
@@ -360,9 +360,9 @@
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
function toggleSound() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Контракты — CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_nav.html" %}
|
||||
@@ -704,10 +704,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/app.min.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
||||
</body>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crash - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
<style>
|
||||
.crash-layout { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.crash-canvas-container {
|
||||
@@ -116,9 +116,9 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
const canvas = document.getElementById('crashCanvas');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CS2 Trade-Up Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_nav.html" %}
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/app.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Вход - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
@@ -820,9 +820,9 @@ data-price="{{ item.price_rub }}">
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Регистрация - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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">
|
||||
<title>Апгрейд — CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body class="upgrade-page">
|
||||
{% include "_nav.html" %}
|
||||
@@ -759,11 +759,11 @@
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.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>
|
||||
function handleAchievements(data) {
|
||||
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
||||
|
||||
+59
-40
@@ -1,7 +1,9 @@
|
||||
import random
|
||||
import math
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database import User, UpgradeRPU, Upgrade
|
||||
|
||||
MAX_WIN_STREAK = 3
|
||||
@@ -9,7 +11,6 @@ MAX_LOSE_STREAK = 5
|
||||
|
||||
|
||||
def get_upgrade_rpu(db: Session, user_id: int) -> UpgradeRPU:
|
||||
"""Получает или создает настройки РПУ апгрейдов"""
|
||||
rpu = db.query(UpgradeRPU).filter(UpgradeRPU.user_id == user_id).first()
|
||||
if not rpu:
|
||||
rpu = UpgradeRPU(user_id=user_id)
|
||||
@@ -20,82 +21,80 @@ def get_upgrade_rpu(db: Session, user_id: int) -> UpgradeRPU:
|
||||
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:
|
||||
if input_price <= 0 or target_price <= 0:
|
||||
return 50.0
|
||||
|
||||
if target_price <= input_price:
|
||||
return -1.0
|
||||
|
||||
probability = (input_price / target_price) * 100
|
||||
|
||||
return max(1.0, min(75.0, probability))
|
||||
|
||||
|
||||
def get_adjusted_upgrade_probability(db: Session, user_id: int, base_probability: float) -> float:
|
||||
if base_probability < 0:
|
||||
return -1.0
|
||||
|
||||
rpu = get_upgrade_rpu(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))
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def spin_upgrade_wheel(success_probability: float) -> tuple[bool, float, int, float]:
|
||||
raw_angle = random.uniform(0, 360)
|
||||
|
||||
half_green = (success_probability / 100) * 180
|
||||
|
||||
success = 180 - half_green <= raw_angle <= 180 + half_green
|
||||
|
||||
margin = max(3, half_green * 0.12)
|
||||
|
||||
if success:
|
||||
rotations = random.randint(7, 9)
|
||||
lo = max(0, 180 - half_green + margin)
|
||||
hi = min(360, 180 + half_green - margin)
|
||||
if lo < hi:
|
||||
target_angle = random.uniform(lo, hi)
|
||||
else:
|
||||
target_angle = random.uniform(max(0, 180 - half_green + 1), min(360, 180 + half_green - 1))
|
||||
target_angle = random.uniform(lo, hi) if lo < hi else random.uniform(
|
||||
max(0, 180 - half_green + 1), min(360, 180 + half_green - 1))
|
||||
else:
|
||||
rotations = random.randint(6, 8)
|
||||
left_size = max(0, 180 - half_green - 3)
|
||||
right_start = min(360, 180 + half_green + 3)
|
||||
right_size = max(0, 360 - right_start)
|
||||
if left_size >= right_size:
|
||||
lo = 0
|
||||
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)
|
||||
|
||||
lo, hi = (0, max(0, 180 - half_green - 3)) if left_size >= right_size else (min(360, 180 + half_green + 3), 360)
|
||||
target_angle = random.uniform(lo, hi) if lo < hi else random.uniform(min(360, 180 + half_green + 3), 360)
|
||||
final_angle = target_angle + rotations * 360
|
||||
|
||||
return success, final_angle, rotations, raw_angle
|
||||
|
||||
|
||||
def update_upgrade_stats(db: Session, user_id: int, success: bool, input_price: float):
|
||||
rpu = get_upgrade_rpu(db, user_id)
|
||||
rpu.total_attempts += 1
|
||||
|
||||
if success:
|
||||
rpu.total_success += 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
|
||||
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
|
||||
_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