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

This commit is contained in:
2026-07-25 13:31:40 +00:00
parent 47c7f17c90
commit 17da0b6d79
28 changed files with 1840 additions and 3244 deletions
+1
View File
@@ -5,3 +5,4 @@
__pycache__/
*.pyc
.DS_Store
venv/
+677 -2437
View File
File diff suppressed because it is too large Load Diff
+66 -58
View File
@@ -3,127 +3,135 @@ from typing import Optional
from jose import JWTError, jwt
from 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
+67
View File
@@ -0,0 +1,67 @@
import os
import json
import asyncio
from typing import Optional, Any, Callable, Awaitable
from datetime import timedelta
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CACHE_TTL = int(os.getenv("CACHE_TTL", "300"))
_redis_pool = None
_lock = asyncio.Lock()
async def get_redis():
global _redis_pool
if _redis_pool is None:
import aioredis
async with _lock:
if _redis_pool is None:
_redis_pool = await aioredis.from_url(REDIS_URL, decode_responses=True)
return _redis_pool
async def cache_get(key: str) -> Optional[Any]:
try:
r = await get_redis()
val = await r.get(key)
if val:
return json.loads(val)
except Exception:
pass
return None
async def cache_set(key: str, value: Any, ttl: int = CACHE_TTL):
try:
r = await get_redis()
await r.setex(key, ttl, json.dumps(value, default=str))
except Exception:
pass
async def cache_delete(key: str):
try:
r = await get_redis()
await r.delete(key)
except Exception:
pass
async def cache_get_or_set(key: str, factory: Callable[[], Awaitable[Any]], ttl: int = CACHE_TTL) -> Any:
cached = await cache_get(key)
if cached is not None:
return cached
value = await factory()
await cache_set(key, value, ttl)
return value
async def cache_invalidate_pattern(pattern: str):
try:
r = await get_redis()
keys = await r.keys(pattern)
if keys:
await r.delete(*keys)
except Exception:
pass
+171 -215
View File
@@ -1,16 +1,33 @@
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
if is_sqlite:
with engine.connect() as conn:
conn.execute(text("PRAGMA journal_mode=WAL"))
conn.execute(text("PRAGMA synchronous=NORMAL"))
@@ -18,10 +35,25 @@ with engine.connect() as conn:
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")
# ─── Create tables ──────────────────────────────────────────────────────────
def init_sync_db():
Base.metadata.create_all(bind=engine)
_run_migrations(engine, is_sync=True)
# Миграция: добавляем недостающие индексы для производительности
async def init_async_db():
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
def _run_migrations(engine_or_conn, is_sync: bool):
try:
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")
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] Миграция индексов: {e}")
print(f"[DB] Migration check: {e}")
# Миграция: добавляем колонку hidden если её нет
try:
from sqlalchemy import inspect
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("achievements")]
if "hidden" not in cols:
from sqlalchemy import text
with engine.connect() as conn:
conn.execute(text("ALTER TABLE achievements ADD COLUMN hidden BOOLEAN DEFAULT 0"))
conn.commit()
except Exception as e:
print(f"[DB] Миграция hidden: {e}")
# Миграция: добавляем image_url и price_rub в inventory_items
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
try:
from backend import ALL_ITEMS
with engine.connect() as conn:
rows = conn.execute(text("SELECT id, item_id FROM inventory_items WHERE image_url = '' OR price_rub = 0.0")).fetchall()
for row_id, item_id in rows:
if 0 <= item_id < len(ALL_ITEMS):
item = ALL_ITEMS[item_id]
img = item.get("image_url", "")
pr = item.get("price_rub", 0.0)
conn.execute(
text("UPDATE inventory_items SET image_url = :img, price_rub = :pr WHERE id = :rid"),
{"img": img, "pr": pr, "rid": row_id}
)
conn.commit()
print(f"[DB] Backfilled {len(rows)} inventory_items from ALL_ITEMS")
except Exception as e2:
print(f"[DB] Backfill inventory_items: {e2}")
except Exception as e:
print(f"[DB] Миграция inventory_items: {e}")
# Миграция: новые поля в users (карта)
try:
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("users")]
if "card_balance_cached" not in cols:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE users ADD COLUMN card_balance_cached FLOAT DEFAULT 1000.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN total_deposited FLOAT DEFAULT 0.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN total_withdrawn FLOAT DEFAULT 0.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN last_deposit_date TIMESTAMP"))
conn.commit()
print("[DB] Миграция users (card): OK")
except Exception as e:
print(f"[DB] Миграция users card: {e}")
# Миграция: новые поля в user_rpu (v2)
try:
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("user_rpu")]
for col, coltype in [
("luck_budget", "FLOAT DEFAULT 100.0"),
("session_spent", "FLOAT DEFAULT 0.0"),
("session_won", "FLOAT DEFAULT 0.0"),
("session_reset_date", "TIMESTAMP"),
("ceiling_multiplier", "FLOAT DEFAULT 1.0"),
("ceiling_break_count", "INTEGER DEFAULT 0"),
("ceiling_break_session", "INTEGER DEFAULT 0"),
("consecutive_loss_value", "FLOAT DEFAULT 0.0"),
("consecutive_loss_count", "INTEGER DEFAULT 0"),
("comeback_active", "BOOLEAN DEFAULT 0"),
("comeback_openings_left", "INTEGER DEFAULT 0"),
("comeback_multiplier", "FLOAT DEFAULT 1.0"),
("hot_score", "FLOAT DEFAULT 0.0"),
("last_activity_date", "TIMESTAMP"),
]:
if col not in cols:
with engine.connect() as conn:
conn.execute(text(f"ALTER TABLE user_rpu ADD COLUMN {col} {coltype}"))
conn.commit()
print(f"[DB] Миграция user_rpu: {col}")
except Exception as e:
print(f"[DB] Миграция user_rpu: {e}")
# Миграция: создаём PromoCode если нет
try:
Base.metadata.tables["promo_codes"].create(bind=engine, checkfirst=True)
except:
pass
# Миграция: создаём TransactionLog если нет
try:
Base.metadata.tables["transaction_log"].create(bind=engine, checkfirst=True)
except:
pass
def get_db():
# ─── 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)
+75 -17
View File
@@ -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,
}
# Сидируем достижения при старте
try:
from database import init_sync_db
init_sync_db()
db_seed = next(get_db())
seed_achievements(db_seed)
from achievements import ACHIEVEMENT_DEFS
from database import Achievement
existing = db_seed.query(Achievement).count()
if existing == 0:
for defn in ACHIEVEMENT_DEFS:
db_seed.add(Achievement(**defn))
db_seed.commit()
print(f"[Seed] Добавлено {len(ACHIEVEMENT_DEFS)} достижений")
else:
existing_names = {a.name for a in db_seed.query(Achievement.name).all()}
added = 0
for defn in ACHIEVEMENT_DEFS:
if defn["name"] not in existing_names:
db_seed.add(Achievement(**defn))
added += 1
if added:
db_seed.commit()
print(f"[Seed] Добавлено {added} новых достижений")
db_seed.close()
except Exception as e:
print(f"[Seed] {e}")
import traceback
traceback.print_exc()
async def broadcast_online_count():
"""Рассылает всем количество игроков онлайн"""
+85
View File
@@ -0,0 +1,85 @@
"""
Миграция данных из SQLite в PostgreSQL.
Запуск: python migrate_db.py
Требует настроенного PostgreSQL (см. database.py)
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from database import DATABASE_URL, ASYNC_DATABASE_URL, Base, User, InventoryItem, CaseOpening, Contract
from database import Achievement, UserAchievement, ActivityFeed, UserRPU, UpgradeRPU, Upgrade, PromoCode, TransactionLog
# Sync SQLite source
SOURCE_URL = DATABASE_URL
# Sync PostgreSQL target (derive from async URL)
TARGET_URL = ASYNC_DATABASE_URL.replace("+asyncpg", "").replace("+aiosqlite", "")
if "sqlite" in SOURCE_URL:
print(f"Source: {SOURCE_URL}")
print(f"Target: {TARGET_URL}")
print("---")
else:
print("Source already PostgreSQL — nothing to migrate.")
sys.exit(0)
source_engine = create_engine(SOURCE_URL)
target_engine = create_engine(TARGET_URL, pool_pre_ping=True)
SourceSession = sessionmaker(bind=source_engine)
TargetSession = sessionmaker(bind=target_engine)
def migrate_table(model, target_session):
"""Copies all rows from source model to target model"""
source_session = SourceSession()
try:
rows = source_session.query(model).all()
if not rows:
return 0
count = 0
for row in rows:
# Detach from source session
source_session.expunge(row)
target_session.merge(row)
count += 1
target_session.commit()
return count
except Exception as e:
target_session.rollback()
print(f" ERROR: {e}")
return 0
finally:
source_session.close()
def main():
print("Creating target tables...")
Base.metadata.create_all(bind=target_engine)
target_session = TargetSession()
models = [
(User, "users"),
(UserRPU, "user_rpu"),
(UpgradeRPU, "upgrade_rpu"),
(InventoryItem, "inventory_items"),
(CaseOpening, "case_openings"),
(Contract, "contracts"),
(Achievement, "achievements"),
(UserAchievement, "user_achievements"),
(ActivityFeed, "activity_feed"),
(Upgrade, "upgrades"),
(PromoCode, "promo_codes"),
(TransactionLog, "transaction_log"),
]
for model, name in models:
print(f"Migrating {name}... ", end="", flush=True)
count = migrate_table(model, target_session)
print(f"{count} rows")
target_session.close()
print("\nMigration complete! Set ASYNC_DATABASE_URL to the PostgreSQL URL to use it.")
if __name__ == "__main__":
main()
+3 -1
View File
@@ -6,9 +6,11 @@ markupsafe>=3.0.0
sqlalchemy>=2.0
python-jose[cryptography]
passlib
bcrypt
bcrypt>=4.0.0
python-multipart
python-dateutil
aiofiles
tqdm
aiosqlite
asyncpg
redis>=5.0
+240 -414
View File
@@ -3,21 +3,16 @@ import random
import math
from datetime import datetime, date
from typing import Dict, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from database import User, UserRPU, InventoryItem
from promo_phase import get_promo_effect_for_user, get_current_phase
# Стандартные веса редкостей
BASE_RARITY_WEIGHTS = {
"Consumer Grade": 10000,
"Industrial Grade": 5000,
"Mil-Spec": 2500,
"Mil-Spec Grade": 2500,
"Restricted": 625,
"Classified": 125,
"Covert": 25,
"Rare Special Item": 5,
"Extraordinary": 5
"Consumer Grade": 10000, "Industrial Grade": 5000,
"Mil-Spec": 2500, "Mil-Spec Grade": 2500,
"Restricted": 625, "Classified": 125,
"Covert": 25, "Rare Special Item": 5, "Extraordinary": 5
}
RARITY_TO_FIELD = {
@@ -33,421 +28,321 @@ RARITY_TO_FIELD = {
}
RARITY_RANK = {
"Consumer Grade": 0,
"Industrial Grade": 1,
"Mil-Spec": 2,
"Mil-Spec Grade": 2,
"Restricted": 3,
"Classified": 4,
"Covert": 5,
"Rare Special Item": 6,
"Extraordinary": 6
"Consumer Grade": 0, "Industrial Grade": 1,
"Mil-Spec": 2, "Mil-Spec Grade": 2,
"Restricted": 3, "Classified": 4, "Covert": 5,
"Rare Special Item": 6, "Extraordinary": 6
}
# Пороги потолка
CEILING_BASE_MULTIPLIER = 3.0
CEILING_MIN = 1000.0
CEILING_SESSION_HISTORY = {
0: 5.0, # первая сессия
1: 4.0, # вторая
2: 3.5, # третья
-1: 3.0 # 3+ сессий
}
CEILING_SESSION_HISTORY = {0: 5.0, 1: 4.0, 2: 3.5, -1: 3.0}
# Пороги для luck_budget
BUDGET_MAX = 100.0
BUDGET_CONSUMPTION_RATE = 0.001 # 0.1% от цены предмета
BUDGET_REGEN_PER_OPEN = 0.5 # +0.5% за каждое открытие
BUDGET_LOW_THRESHOLD = 20.0 # если budget < 20%, удача падает
BUDGET_CONSUMPTION_RATE = 0.001
BUDGET_REGEN_PER_OPEN = 0.5
BUDGET_LOW_THRESHOLD = 20.0
# Комбек
COMEBACK_TRIGGER_COUNT = 5 # 5+ проигрышей подряд → комбек
COMEBACK_TRIGGER_VALUE_RATIO = 0.3 # потеря >30% от total_deposited
COMEBACK_OPENINGS = 5 # комбек на 5 открытий
COMEBACK_MULTIPLIER_MAX = 3.0 # макс множитель удачи в комбеке
COMEBACK_TRIGGER_COUNT = 5
COMEBACK_TRIGGER_VALUE_RATIO = 0.3
COMEBACK_OPENINGS = 5
COMEBACK_MULTIPLIER_MAX = 3.0
# Hot/Cold
HOT_THRESHOLD_SPENT = 10000 # потратил >10k
HOT_THRESHOLD_WITHDRAWAL = 0.1 # вывел <10% от депозитов
COLD_DAYS_INACTIVE = 7 # 7 дней неактивен = холодный
COLD_SPENT_PER_DAY = 100 # <100₽/день = холодный
HOT_THRESHOLD_SPENT = 10000
HOT_THRESHOLD_WITHDRAWAL = 0.1
COLD_DAYS_INACTIVE = 7
COLD_SPENT_PER_DAY = 100
def get_user_rpu(db: Session, user_id: int) -> UserRPU:
rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first()
if not rpu:
rpu = UserRPU(user_id=user_id)
db.add(rpu)
_rpu_commit_or_flush(db)
db.refresh(rpu)
return rpu
# ── Session Management ──
def check_and_reset_session(db: Session, user_id: int):
"""
Проверяет, не пора ли сбросить сессию (каждый календарный день).
"""
rpu = get_user_rpu(db, user_id)
today = date.today()
if rpu.session_reset_date is None:
rpu.session_reset_date = datetime.utcnow()
rpu.luck_budget = BUDGET_MAX
rpu.session_spent = 0
rpu.session_won = 0
rpu.ceiling_break_session = 0
rpu.comeback_active = False
rpu.comeback_openings_left = 0
rpu.comeback_multiplier = 1.0
rpu.consecutive_loss_count = 0
rpu.consecutive_loss_value = 0.0
_rpu_commit_or_flush(db)
return
last_reset = rpu.session_reset_date.date()
if last_reset < today:
rpu.session_reset_date = datetime.utcnow()
rpu.luck_budget = BUDGET_MAX
rpu.session_spent = 0
rpu.session_won = 0
rpu.ceiling_break_session = 0
rpu.comeback_active = False
rpu.comeback_openings_left = 0
rpu.comeback_multiplier = 1.0
rpu.consecutive_loss_count = 0
rpu.consecutive_loss_value = 0.0
_rpu_commit_or_flush(db)
# ── Ceiling (Потолок) ──
def calculate_ceiling(db: Session, user_id: int) -> float:
"""
Потолок = total_deposited * mult.
mult зависит от количества сессий на сайте.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return CEILING_MIN
total_dep = max(0.0, user.total_deposited)
# Считаем, сколько прошло сессий (дней с первой транзакции)
rpu = get_user_rpu(db, user_id)
session_count = 0
if rpu.session_reset_date and user.created_at:
days_since = (datetime.utcnow() - user.created_at).days
session_count = max(0, days_since)
if session_count == 0:
mult = CEILING_SESSION_HISTORY[0]
elif session_count == 1:
mult = CEILING_SESSION_HISTORY[1]
elif session_count == 2:
mult = CEILING_SESSION_HISTORY[2]
else:
mult = CEILING_SESSION_HISTORY[-1]
# Учитываем ceiling_multiplier (временные модификаторы)
mult *= rpu.ceiling_multiplier
ceiling = total_dep * mult
return max(CEILING_MIN, ceiling)
def get_total_inventory_value(db: Session, user_id: int) -> float:
"""Суммарная стоимость инвентаря пользователя"""
items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all()
return sum(item.price_rub or 0 for item in items)
def get_user_total_value(db: Session, user_id: int) -> float:
"""Баланс + инвентарь"""
user = db.query(User).filter(User.id == user_id).first()
balance = user.balance if user else 0
inv = get_total_inventory_value(db, user_id)
return balance + inv
def get_ceiling_luck_factor(db: Session, user_id: int) -> float:
"""
Возвращает множитель удачи на основе близости к потолку (0.31.0).
Чем ближе к потолку тем меньше множитель.
Не блокирует открытие, а лишь делает удачу чуть хуже.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return 1.0
ceiling = calculate_ceiling(db, user_id)
inv_value = get_total_inventory_value(db, user_id)
withdrawn = user.total_withdrawn or 0
extracted_value = inv_value + withdrawn
if ceiling <= 0:
return 1.0
ratio = extracted_value / ceiling
if ratio < 0.8:
return 1.0
if ratio >= 1.0:
return 0.3
return 1.0 - (ratio - 0.8) / 0.2 * 0.7
def check_ceiling_breach(db: Session, user_id: int, item_price: float,
case_price: float) -> bool:
"""
Проверяет, пробивает ли предмет потолок.
Если предмет дороже 50% от потолка пробитие.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return False
ceiling = calculate_ceiling(db, user_id)
total_value = user.balance + get_total_inventory_value(db, user_id)
# Если инвентарь уже под потолком — не пробиваем
if total_value < ceiling:
return False
# Сам предмет должен быть дорогим
if item_price < ceiling * 0.5:
return False
return True
def apply_ceiling_breach(db: Session, user_id: int, item_price: float):
"""
Пробитие потолка: расширяем потолок на 50% на остаток сессии.
"""
rpu = get_user_rpu(db, user_id)
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5)
rpu.ceiling_break_count += 1
rpu.ceiling_break_session += 1
_rpu_commit_or_flush(db)
def apply_ceiling_withdrawal_penalty(db: Session, user_id: int):
"""
При большом выводе скручиваем потолок на следующих N сессий.
"""
rpu = get_user_rpu(db, user_id)
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
_rpu_commit_or_flush(db)
# ── Luck Budget ──
# Флаг: если True — sub-функции делают flush вместо commit.
# Устанавливается в True на время горячего пути (case_open, upgrade).
_BATCH_MODE = False
# Кэш 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)),
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# CS2 Simulator — Production Start Script
# Starts: PostgreSQL-backed web app + WebSocket server
set -e
# Ensure PostgreSQL is running
pg_isready -q || service postgresql start
# Ensure Redis is running
redis-cli ping > /dev/null 2>&1 || redis-server --daemonize yes
# Set production env
export USE_ASYNC=1
export ASYNC_DATABASE_URL="${ASYNC_DATABASE_URL:-postgresql+asyncpg://dodep:dodep123@localhost/dodep}"
export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}"
echo "=== Starting CS2 Simulator ==="
echo "DB: $ASYNC_DATABASE_URL"
echo "WS Server: port 13670"
echo "Web App: port 13669"
# Kill old processes
pkill -f "uvicorn frontend:app" 2>/dev/null || true
pkill -f "uvicorn ws_server:app" 2>/dev/null || true
sleep 1
# Start WebSocket server
nohup venv/bin/python -m uvicorn ws_server:app --host 0.0.0.0 --port 13670 --workers 1 > ws_server.log 2>&1 &
echo "[WS] Started on port 13670"
# Start main web app with multiple workers
nohup venv/bin/python -m uvicorn frontend:app --host 0.0.0.0 --port 13669 --workers 4 --loop uvloop > frontend.log 2>&1 &
echo "[WEB] Started on port 13669 (4 workers)"
echo "=== Ready ==="
echo "Web: http://localhost:13669"
echo "WS: ws://localhost:13670/ws/{user_id}"
+1
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
async function logout(){try{await fetch('/web/api/auth/logout',{method:'POST'});}catch(e){}
window.location.href='/';}
async function refreshBalance(){try{const res=await fetch('/web/api/user/balance');const data=await res.json();if(data.success){const el=document.getElementById('siteBalanceDisplay');if(el){el.innerHTML=Math.floor(data.balance).toLocaleString('ru')+' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';}}}catch(e){}}
function getRarityColor(rarity){const colors={'Consumer Grade':'#b0b0b0','Industrial Grade':'#5e98d9','Mil-Spec':'#4b69ff','Mil-Spec Grade':'#4b69ff','Restricted':'#8847ff','Classified':'#d32ce6','Covert':'#eb4b4b','Rare Special Item':'#ffd700','Extraordinary':'#ffd700',};return colors[rarity]||'#b0b0b0';}
function formatNumber(n){return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g,' ');}
function debounce(fn,ms=300){let timer;return function(...args){clearTimeout(timer);timer=setTimeout(()=>fn.apply(this,args),ms);};}
function escapeHtml(str){const div=document.createElement('div');div.textContent=str;return div.innerHTML;}
function showNotification(message,type='info',duration=3000){const existing=document.querySelector('.notification-toast');if(existing)existing.remove();const colors={success:'#22c55e',error:'#ef4444',info:'#3b82f6',warning:'#f59e0b',};const toast=document.createElement('div');toast.className='notification-toast';toast.style.cssText=`position:fixed;top:20px;right:20px;z-index:99999;background:#1a1a2e;border:1px solid ${colors[type]||colors.info};color:white;padding:12px 20px;border-radius:8px;font-size:14px;box-shadow:0 10px 40px rgba(0,0,0,0.5);animation:slideInRight 0.3s ease;max-width:400px;`;toast.textContent=message;document.body.appendChild(toast);setTimeout(()=>{toast.style.animation='slideOutRight 0.3s ease forwards';setTimeout(()=>toast.remove(),300);},duration);}
if(!document.getElementById('notificationStyles')){const style=document.createElement('style');style.id='notificationStyles';style.textContent=`@keyframes slideInRight{from{transform:translateX(120%);opacity:0;}
to{transform:translateX(0);opacity:1;}}@keyframes slideOutRight{from{transform:translateX(0);opacity:1;}
to{transform:translateX(120%);opacity:0;}}`;document.head.appendChild(style);}
function showAchievementPopup(title,reward){let popup=document.getElementById('achievementGlobalPopup');if(!popup){popup=document.createElement('div');popup.id='achievementGlobalPopup';popup.style.cssText=`position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:99999;background:linear-gradient(135deg,#1a3a1a,#0d260d);border:1px solid#22c55e;border-radius:12px;padding:1rem 1.5rem;box-shadow:0 10px 40px rgba(0,0,0,0.5);animation:slideInDown 0.5s ease;max-width:400px;width:90%;text-align:center;`;popup.innerHTML=`<div style="font-size:0.85rem;color:#94a3b8;margin-bottom:0.25rem;">🏆 Новое достижение!</div><div style="font-size:1.1rem;font-weight:600;"id="globalPopupTitle"></div><div style="color:#22c55e;font-size:0.9rem;margin-top:0.25rem;"id="globalPopupReward"></div>`;document.body.appendChild(popup);if(!document.getElementById('slideInDownStyle')){const s=document.createElement('style');s.id='slideInDownStyle';s.textContent=`@keyframes slideInDown{from{transform:translateX(-50%)translateY(-120%);opacity:0;}
to{transform:translateX(-50%)translateY(0);opacity:1;}}`;document.head.appendChild(s);}}
document.getElementById('globalPopupTitle').textContent=title;document.getElementById('globalPopupReward').textContent='';popup.style.display='block';if(window.SoundManager)SoundManager.achievement();setTimeout(()=>{popup.style.animation='slideInDown 0.5s ease reverse forwards';setTimeout(()=>{popup.style.display='none';popup.style.animation='slideInDown 0.5s ease';},500);},4000);}
+19
View File
@@ -0,0 +1,19 @@
(function(){'use strict';let toastId=0;const container=document.createElement('div');container.className='toast-container';container.id='toastContainer';document.body.appendChild(container);function escapeHtml(text){const d=document.createElement('div');d.textContent=text||'';return d.innerHTML;}
window.Notify={toast:function(message,type,title){if(!message)return;type=type||'info';const icons={success:'✓',error:'✕',info:'',warning:'⚠'};const titles={success:title||'Успешно',error:title||'Ошибка',info:title||'Информация',warning:title||'Внимание'};const icon=icons[type]||'';const t=titles[type];const id=++toastId;const el=document.createElement('div');el.className='toast toast-'+type;el.id='toast-'+id;el.innerHTML='<div class="toast-icon">'+icon+'</div>'+
'<div class="toast-content">'+
'<div class="toast-title">'+escapeHtml(t)+'</div>'+
'<div class="toast-message">'+escapeHtml(message)+'</div>'+
'</div>'+
'<button class="toast-close" onclick="Notify.dismiss('+id+')">✕</button>';container.appendChild(el);const timeout=setTimeout(function(){Notify.dismiss(id);},4000);el._timeout=timeout;el._id=id;return id;},success:function(message){return this.toast(message,'success');},error:function(message){return this.toast(message,'error');},info:function(message){return this.toast(message,'info');},warning:function(message){return this.toast(message,'warning');},dismiss:function(id){var el=document.getElementById('toast-'+id);if(!el)return;if(el._timeout)clearTimeout(el._timeout);if(el.classList.contains('toast-removing'))return;el.classList.add('toast-removing');setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},250);},confirm:function(message,title,callback){if(typeof title==='function'){callback=title;title='Подтверждение';}
title=title||'Подтверждение';var overlay=document.createElement('div');overlay.className='confirm-overlay';overlay.innerHTML='<div class="confirm-dialog">'+
'<div class="confirm-dialog-header">'+
'<div class="confirm-dialog-icon confirm-dialog-icon-warning">⚠</div>'+
'<div class="confirm-dialog-title">'+escapeHtml(title)+'</div>'+
'</div>'+
'<div class="confirm-dialog-message">'+escapeHtml(message)+'</div>'+
'<div class="confirm-dialog-actions">'+
'<button class="btn btn-outline confirm-cancel">Отмена</button>'+
'<button class="btn btn-danger confirm-ok">Подтвердить</button>'+
'</div>'+
'</div>';document.body.appendChild(overlay);var result=false;function close(){if(overlay.parentNode)overlay.parentNode.removeChild(overlay);if(callback)callback(result);}
overlay.querySelector('.confirm-cancel').addEventListener('click',function(){result=false;close();});overlay.querySelector('.confirm-ok').addEventListener('click',function(){result=true;close();});overlay.addEventListener('click',function(e){if(e.target===overlay){result=false;close();}});document.addEventListener('keydown',function handler(e){if(e.key==='Escape'){result=false;close();document.removeEventListener('keydown',handler);}});return overlay;}};})();
+4
View File
@@ -0,0 +1,4 @@
const SoundManager={_ctx:null,_enabled:true,_volume:0.3,_activeNodes:new Set(),_timeouts:[],init(){try{this._ctx=new(window.AudioContext||window.webkitAudioContext)();}catch(e){console.warn('Web Audio API not supported');this._enabled=false;}
const saved=localStorage.getItem('sound_enabled');if(saved!==null)this._enabled=saved==='true';},setEnabled(on){this._enabled=on;localStorage.setItem('sound_enabled',on);},toggle(){this.setEnabled(!this._enabled);return this._enabled;},stopAll(){this._timeouts.forEach(clearTimeout);this._timeouts=[];this._activeNodes.forEach(node=>{try{node.stop();}catch(e){}
try{node.disconnect();}catch(e){}});this._activeNodes.clear();},_trackNode(node,duration){this._activeNodes.add(node);node.addEventListener('ended',()=>this._activeNodes.delete(node));setTimeout(()=>{this._activeNodes.delete(node);try{node.disconnect();}catch(e){}},(duration+0.1)*1000);},_play(freq,duration,type='sine',volume=1){if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();const osc=this._ctx.createOscillator();const gain=this._ctx.createGain();osc.type=type;osc.frequency.setValueAtTime(freq,this._ctx.currentTime);gain.gain.setValueAtTime(this._volume*volume,this._ctx.currentTime);gain.gain.exponentialRampToValueAtTime(0.001,this._ctx.currentTime+duration);osc.connect(gain);gain.connect(this._ctx.destination);osc.start();osc.stop(this._ctx.currentTime+duration);this._trackNode(osc,duration);this._trackNode(gain,duration);},_noise(duration,volume=1){if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();const bufferSize=this._ctx.sampleRate*duration;const buffer=this._ctx.createBuffer(1,bufferSize,this._ctx.sampleRate);const data=buffer.getChannelData(0);for(let i=0;i<bufferSize;i++){data[i]=Math.random()*2-1;}
const source=this._ctx.createBufferSource();source.buffer=buffer;const gain=this._ctx.createGain();gain.gain.setValueAtTime(this._volume*volume,this._ctx.currentTime);gain.gain.exponentialRampToValueAtTime(0.001,this._ctx.currentTime+duration);source.connect(gain);gain.connect(this._ctx.destination);source.start();this._trackNode(source,duration);this._trackNode(gain,duration);},_delay(fn,ms){const id=setTimeout(()=>{this._timeouts=this._timeouts.filter(t=>t!==id);fn();},ms);this._timeouts.push(id);return id;},caseOpen(){this._play(800,0.1,'square',0.3);this._delay(()=>this._play(400,0.15,'sawtooth',0.2),80);this._delay(()=>this._play(200,0.2,'sine',0.15),180);this._noise(0.05,0.4);},caseRare(){this._play(523,0.15,'sine',0.3);this._delay(()=>this._play(659,0.15,'sine',0.3),100);this._delay(()=>this._play(784,0.15,'sine',0.3),200);this._delay(()=>this._play(1047,0.3,'sine',0.4),300);},caseCovert(){this._play(392,0.2,'sawtooth',0.3);this._delay(()=>this._play(523,0.2,'sawtooth',0.3),150);this._delay(()=>this._play(659,0.2,'sawtooth',0.3),300);this._delay(()=>this._play(784,0.4,'sine',0.5),450);this._delay(()=>this._play(1047,0.6,'sine',0.6),600);},slotSpin(){for(let i=0;i<8;i++){this._delay(()=>{this._play(300+Math.random()*400,0.05,'square',0.15);},i*80);}},slotMatch(){this._play(523,0.1,'sine',0.3);this._delay(()=>this._play(659,0.1,'sine',0.3),100);this._delay(()=>this._play(784,0.1,'sine',0.3),200);this._delay(()=>this._play(1047,0.4,'sine',0.5),300);},contractSubmit(){this._play(150,0.3,'sawtooth',0.2);this._delay(()=>this._play(200,0.2,'square',0.2),200);this._delay(()=>this._play(250,0.1,'square',0.15),350);},contractResult(){this._play(400,0.15,'sine',0.3);this._delay(()=>this._play(600,0.15,'sine',0.3),120);this._delay(()=>this._play(800,0.3,'sine',0.4),240);},wheelSpin(){this._play(600+Math.random()*400,0.04,'triangle',0.12);this._play(200,0.02,'square',0.06);},wheelWin(){const t=this._ctx.currentTime;if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();[523,659,784,1047].forEach((freq,i)=>{const osc=this._ctx.createOscillator();const gain=this._ctx.createGain();osc.type='sine';osc.frequency.setValueAtTime(freq,t+i*0.1);gain.gain.setValueAtTime(0,t+i*0.1);gain.gain.linearRampToValueAtTime(this._volume*0.35,t+i*0.1+0.05);gain.gain.exponentialRampToValueAtTime(0.001,t+i*0.1+0.5);osc.connect(gain);gain.connect(this._ctx.destination);osc.start(t+i*0.1);osc.stop(t+i*0.1+0.5);this._trackNode(osc,0.6);this._trackNode(gain,0.6);});this._delay(()=>this._play(1568,0.4,'sine',0.25),350);this._delay(()=>this._play(2093,0.6,'sine',0.2),500);},wheelLose(){this._play(300,0.15,'sawtooth',0.25);this._delay(()=>this._play(200,0.2,'sawtooth',0.2),100);this._delay(()=>this._play(120,0.3,'sawtooth',0.15),220);this._noise(0.15,0.3);this._delay(()=>{this._play(60,0.5,'sine',0.2);this._play(45,0.6,'sine',0.15);},350);},achievement(){this._play(659,0.1,'sine',0.3);this._delay(()=>this._play(523,0.1,'sine',0.3),100);this._delay(()=>this._play(784,0.1,'sine',0.3),200);this._delay(()=>this._play(659,0.1,'sine',0.3),300);this._delay(()=>this._play(1047,0.4,'sine',0.5),400);},crashBet(){this._play(300,0.1,'square',0.15);},crashTick(){this._play(500+Math.random()*500,0.03,'sine',0.08);},crashCrashed(){this._noise(0.3,0.6);this._play(80,0.5,'sawtooth',0.4);this._delay(()=>this._play(50,0.8,'sine',0.3),100);},crashCashout(){this._play(600,0.1,'sine',0.3);this._delay(()=>this._play(800,0.1,'sine',0.3),80);this._delay(()=>this._play(1000,0.2,'sine',0.4),160);},click(){this._play(800,0.03,'sine',0.1);},error(){this._play(200,0.15,'square',0.2);},balanceUpdate(){this._play(1000,0.05,'sine',0.1);}};document.addEventListener('DOMContentLoaded',()=>SoundManager.init());
+4
View File
@@ -0,0 +1,4 @@
const WS={_ws:null,_reconnectTimer:null,_listeners:{},_connected:false,connect(){if(this._ws&&this._ws.readyState===WebSocket.OPEN)return;const protocol=window.location.protocol==='https:'?'wss:':'ws:';const url=`${protocol}
try{this._ws=new WebSocket(url);}catch(e){console.warn('WS connection failed, retrying in 5s');this._scheduleReconnect();return;}
this._ws.onopen=()=>{this._connected=true;this._emit('connected');};this._ws.onclose=()=>{this._connected=false;this._emit('disconnected');this._scheduleReconnect();};this._ws.onerror=()=>{this._emit('error');};this._ws.onmessage=(event)=>{try{const data=JSON.parse(event.data);this._emit(data.type,data);}catch(e){}};this._pingInterval=setInterval(()=>{if(this._ws&&this._ws.readyState===WebSocket.OPEN){this._ws.send(JSON.stringify({type:'ping'}));}},30000);},disconnect(){if(this._pingInterval)clearInterval(this._pingInterval);if(this._reconnectTimer)clearTimeout(this._reconnectTimer);if(this._ws){this._ws.onclose=null;this._ws.close();this._ws=null;}
this._connected=false;},_scheduleReconnect(){if(this._reconnectTimer)return;this._reconnectTimer=setTimeout(()=>{this._reconnectTimer=null;this.connect();},5000);},on(event,callback){if(!this._listeners[event])this._listeners[event]=[];this._listeners[event].push(callback);return()=>{this._listeners[event]=this._listeners[event].filter(cb=>cb!==callback);};},_emit(event,data){(this._listeners[event]||[]).forEach(cb=>cb(data));(this._listeners['*']||[]).forEach(cb=>cb(event,data));},isConnected(){return this._connected;}};document.addEventListener('DOMContentLoaded',()=>WS.connect());
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta 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', () => {
+2 -2
View File
@@ -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');
+5 -5
View File
@@ -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 -4
View File
@@ -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() {
+5 -5
View File
@@ -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 -4
View File
@@ -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');
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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 -4
View File
@@ -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>
+1 -1
View File
@@ -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" %}
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,254 @@
import os
import json
import asyncio
import random
import math
import time
from typing import Set, Dict, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from contextlib import asynccontextmanager
import aioredis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CRASH_SPEED = 0.08
CRASH_MIN_WAIT = 5.0
CRASH_MAX_WAIT = 10.0
CRASH_RUNNING_TIME = 60.0
CRASH_COOLDOWN = 3.0
app = FastAPI(title="CS2 Simulator WebSocket Server")
class RedisPubSub:
def __init__(self):
self.pub: Optional[aioredis.Redis] = None
self.sub: Optional[aioredis.Redis] = None
async def connect(self):
self.pub = await aioredis.from_url(REDIS_URL, decode_responses=True)
self.sub = await aioredis.from_url(REDIS_URL, decode_responses=True)
async def publish(self, channel: str, message: dict):
if self.pub:
await self.pub.publish(channel, json.dumps(message, default=str))
async def subscribe(self, channel: str):
if self.sub:
psub = self.sub.pubsub()
await psub.subscribe(channel)
return psub
return None
async def close(self):
if self.pub:
await self.pub.close()
if self.sub:
await self.sub.close()
redis_pubsub = RedisPubSub()
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[int, Set[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, user_id: int):
await websocket.accept()
async with self._lock:
if user_id not in self.active_connections:
self.active_connections[user_id] = set()
self.active_connections[user_id].add(websocket)
async def disconnect(self, websocket: WebSocket, user_id: int):
async with self._lock:
if user_id in self.active_connections:
self.active_connections[user_id].discard(websocket)
if not self.active_connections[user_id]:
del self.active_connections[user_id]
async def send_personal(self, user_id: int, message: dict):
async with self._lock:
if user_id in self.active_connections:
dead = set()
for ws in self.active_connections[user_id]:
try:
await ws.send_json(message)
except Exception:
dead.add(ws)
self.active_connections[user_id] -= dead
if not self.active_connections[user_id]:
del self.active_connections[user_id]
async def broadcast(self, message: dict):
async with self._lock:
dead: Dict[int, Set[WebSocket]] = {}
for uid, sockets in self.active_connections.items():
for ws in sockets:
try:
await ws.send_json(message)
except Exception:
dead.setdefault(uid, set()).add(ws)
for uid, dset in dead.items():
self.active_connections[uid] -= dset
if not self.active_connections[uid]:
del self.active_connections[uid]
@property
def online_count(self) -> int:
return len(self.active_connections)
manager = ConnectionManager()
class CrashGame:
def __init__(self):
self.running = False
self.current_multiplier = 1.0
self.crash_point = 1.0
self.bets: Dict[int, float] = {}
self.cashed_out: Dict[int, float] = {}
self.phase = "waiting"
self.timer = 0.0
self.round = 0
self.history: list = []
self._start_time = 0.0
def generate_crash_point(self) -> float:
r = random.random()
cp = 1.0 / max(0.01, 1.0 - r * 0.99)
return round(min(cp, 1000.0), 2)
def get_multiplier(self, elapsed: float) -> float:
return round(math.exp(elapsed * CRASH_SPEED), 2)
def to_dict(self):
return {
"phase": self.phase,
"round": self.round,
"current_multiplier": self.current_multiplier,
"crash_point": self.crash_point,
"timer": int(self.timer),
"bet_count": len(self.bets),
"total_bets": round(sum(self.bets.values()), 2),
"cashed_out": {str(k): v for k, v in self.cashed_out.items()},
"history": self.history[-20:],
}
crash = CrashGame()
async def crash_game_loop():
while True:
if crash.phase == "waiting" or crash.phase == "cooldown":
wait_time = random.uniform(CRASH_MIN_WAIT, CRASH_MAX_WAIT)
for i in range(int(wait_time)):
crash.timer = wait_time - i
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
await asyncio.sleep(1)
crash.timer = 0
crash.phase = "betting"
crash.cashed_out.clear()
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
await asyncio.sleep(5)
crash.phase = "running"
crash.round += 1
crash.crash_point = crash.generate_crash_point()
crash.current_multiplier = 1.0
crash._start_time = time.time()
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
if crash.phase == "running":
while True:
elapsed = time.time() - crash._start_time
mult = crash.get_multiplier(elapsed)
crash.current_multiplier = mult
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
if mult >= crash.crash_point:
crash.phase = "crashed"
crash.history.append({
"round": crash.round,
"crash_point": crash.crash_point,
"timestamp": time.time(),
})
if len(crash.history) > 100:
crash.history = crash.history[-100:]
await manager.broadcast({
"type": "crash_crashed",
"crash_point": crash.crash_point,
"round": crash.round,
})
crash.bets.clear()
crash.phase = "cooldown"
break
if mult > 2.0:
await asyncio.sleep(0.15)
elif mult > 5.0:
await asyncio.sleep(0.08)
else:
await asyncio.sleep(0.2)
await asyncio.sleep(CRASH_COOLDOWN)
async def listen_activity():
psub = await redis_pubsub.subscribe("activity")
if psub:
async for msg in psub.listen():
if msg["type"] == "message":
data = json.loads(msg["data"])
await manager.broadcast(data)
@asynccontextmanager
async def lifespan(app_instance):
await redis_pubsub.connect()
asyncio.create_task(crash_game_loop())
asyncio.create_task(listen_activity())
yield
await redis_pubsub.close()
app.router.lifespan_context = lifespan
@app.websocket("/ws/{user_id}")
async def websocket_endpoint(websocket: WebSocket, user_id: int):
await manager.connect(websocket, user_id)
await websocket.send_json({"type": "connected", "user_id": user_id})
await websocket.send_json({"type": "crash_state", **crash.to_dict()})
try:
while True:
data = await websocket.receive_json()
msg_type = data.get("type")
if msg_type == "crash_bet":
amount = float(data.get("amount", 0))
if amount > 0 and crash.phase == "betting":
crash.bets[user_id] = crash.bets.get(user_id, 0) + amount
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
elif msg_type == "crash_cashout":
if crash.phase == "running" and user_id in crash.bets:
bet = crash.bets.pop(user_id)
crash.cashed_out[user_id] = crash.current_multiplier
payout = round(bet * crash.current_multiplier, 2)
await redis_pubsub.publish("crash_payout", {
"user_id": user_id,
"bet": bet,
"multiplier": crash.current_multiplier,
"payout": payout,
})
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
elif msg_type == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
await manager.disconnect(websocket, user_id)
crash.bets.pop(user_id, None)
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=13670)