Compare commits

..

3 Commits

Author SHA1 Message Date
sasheg 9fed81082f Fix hot-path endpoints: proper async DB sessions and awaited calls
Convert all money-flow endpoints (api_open_case, api_submit_contract,
api_execute_upgrade, api_sell_item, api_sell_bulk_items,
api_open_multiple_cases, api_open_slot_case, api_open_case_animated,
api_crash_bet, api_crash_cashout, api_get_inventory, api_item_detail,
api_rpu_info) to use AsyncSession + await throughout.

Critical bugfix: rpu.py and achievements.py functions (all async def)
were being called without 'await' and with sync Session objects, which
returned coroutine objects instead of actual data. This made RPU
weight adjustment, achievement tracking, and stat updates non-functional.

Also added AsyncSession to imports.
2026-07-25 13:37:39 +00:00
sasheg 17da0b6d79 PostgreSQL + async refactoring, WebSocket server, Redis caching, minified assets 2026-07-25 13:31:40 +00:00
sasheg 47c7f17c90 RPU v2, promo/bank система, админка, оптимизации
— RPU v2: бюджет удачи, потолок (ceiling), комбек после
  проигрышей, hot/cold анализ, интеграция с promo-фазами
— Bank API: mock-банк для карты (депозит/вывод с балансом 1000₽)
— Promo-сервис: автогенерация промокодов каждый час, типы
  (card_to_site, luck_boost, ceiling_boost, free_case, reset_streak)
— Promo-фазы: LUCKY/NEUTRAL/UNLUCKY, переключение каждые 1-6ч
— Admin: история транзакций (user_history), управление промокодами,
  управление promo-фазами, API фильтров/коллекций, RPU info
— Database: WAL mode, новые поля RPU v2, TransactionLog,
  карточный баланс, статистики (streaks, ceiling, budget)
— Frontend: /deposit, /promo/activate, /withdraw, /card-balance,
  /online-count, /rpu-info, lifespan вместо startup event
— UI: user_history.html, улучшения профиля, кейсов, навигации
— Cases: обновление cases.json (1892 строки новых данных)
— Achievements: кэш коллекций, оптимизация is_custom проверок
— Sounds: дополнительные звуковые эффекты
— Upgrade: интеграция с RPU (множители, streaks)
— .gitignore: исключены .db, .bak, __pycache__
2026-07-25 13:11:08 +00:00
44 changed files with 7168 additions and 3977 deletions
+5 -3
View File
@@ -1,6 +1,8 @@
*.db
*.db-shm
*.db-wal
*.bak
__pycache__/
*.pyc
*.pyo
.DS_Store
cs2_simulator.db
*.log
venv/
+737 -2518
View File
File diff suppressed because it is too large Load Diff
+404 -53
View File
@@ -8,7 +8,7 @@ import json
from datetime import datetime
from pathlib import Path
from database import get_db, User, InventoryItem, CaseOpening, Contract
from database import get_db, User, InventoryItem, CaseOpening, Contract, Upgrade, TransactionLog
from auth import get_current_admin
from rpu import get_user_rpu, calculate_rpu_level, get_rpu_description
@@ -184,6 +184,48 @@ async def admin_user_detail(
"inventory": inventory
})
@admin_router.get("/user/{user_id}/history", response_class=HTMLResponse)
async def admin_user_history(
user_id: int,
request: Request,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(404, "Пользователь не найден")
# Все открытия кейсов
openings = db.query(CaseOpening).filter(
CaseOpening.user_id == user_id
).order_by(desc(CaseOpening.opened_at)).limit(200).all()
# Все апгрейды
upgrades = db.query(Upgrade).filter(
Upgrade.user_id == user_id
).order_by(desc(Upgrade.created_at)).limit(200).all()
# Все транзакции
transactions = db.query(TransactionLog).filter(
TransactionLog.user_id == user_id
).order_by(desc(TransactionLog.created_at)).limit(200).all()
from rpu import calculate_ceiling, get_user_total_value, get_user_rpu
ceiling = calculate_ceiling(db, user_id)
total_value = get_user_total_value(db, user_id)
rpu = get_user_rpu(db, user_id)
return templates.TemplateResponse(request, "admin/user_history.html", {
"user": admin,
"target_user": target_user,
"openings": openings,
"upgrades": upgrades,
"transactions": transactions,
"ceiling": ceiling,
"total_value": total_value,
"rpu": rpu,
})
@admin_router.get("/cases", response_class=HTMLResponse)
async def admin_cases(
request: Request,
@@ -197,6 +239,7 @@ async def admin_cases(
for case in cases:
if not isinstance(case.get('items'), list):
case['items'] = []
case['items_count'] = len(case['items'])
all_items = []
for i, item in enumerate(ALL_ITEMS[:500]):
@@ -272,7 +315,7 @@ async def admin_give_item(
if not item_data:
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
item_float = generate_item_float(item_data.get("rarity", "Unknown"))
item_float = item_data.get("mid_float_used") or generate_item_float(item_data.get("rarity", "Unknown"))
inventory_item = InventoryItem(
user_id=user_id,
@@ -282,6 +325,8 @@ async def admin_give_item(
wear=item_data.get("wear", "Unknown"),
float_value=item_float,
type=item_data.get("type", "Normal"),
image_url=item_data.get("image_url", ""),
price_rub=item_data.get("price_rub", 0),
obtained_from="admin"
)
db.add(inventory_item)
@@ -370,6 +415,13 @@ async def admin_get_user_rpu(
rpu = get_user_rpu(db, user_id)
from promo_phase import get_promo_phase_info
promo = get_promo_phase_info()
# Потолок
from rpu import calculate_ceiling
ceiling = calculate_ceiling(db, user_id)
return {
"user_id": user_id,
"username": target_user.username,
@@ -387,7 +439,44 @@ async def admin_get_user_rpu(
"rpu_level": calculate_rpu_level(rpu),
"stats": {
"total_spent": rpu.total_spent,
"total_opened": rpu.total_opened
"total_opened": rpu.total_opened,
"total_value_received": rpu.total_value_received,
"current_streak": rpu.current_streak,
"best_streak": rpu.best_streak,
"worst_streak": rpu.worst_streak
},
# ── RPU v2 ──
"v2": {
"ceiling": {
"total_deposited": target_user.total_deposited or 0,
"ceiling_multiplier": rpu.ceiling_multiplier,
"current_ceiling": ceiling,
"break_count": rpu.ceiling_break_count,
"break_session": rpu.ceiling_break_session,
},
"session": {
"luck_budget": round(rpu.luck_budget, 1),
"session_spent": round(rpu.session_spent, 0),
"session_won": round(rpu.session_won, 0),
"reset_date": rpu.session_reset_date.isoformat() if rpu.session_reset_date else None,
},
"comeback": {
"active": rpu.comeback_active,
"openings_left": rpu.comeback_openings_left,
"multiplier": rpu.comeback_multiplier,
"consecutive_loss_value": round(rpu.consecutive_loss_value or 0, 0),
"consecutive_loss_count": rpu.consecutive_loss_count or 0,
},
"hot_cold": {
"hot_score": round(rpu.hot_score or 0, 2),
"last_activity": rpu.last_activity_date.isoformat() if rpu.last_activity_date else None,
},
"promo_phase": {
"phase": promo["phase"],
"effect": promo["effect"],
"next_switch": promo["next_switch"],
"remaining_minutes": promo["remaining_minutes"],
}
}
}
@@ -481,30 +570,30 @@ async def admin_set_rpu_preset(
@admin_router.post("/api/cases/create")
async def admin_create_case(
name: str = Form(...),
case_name: str = Form(...),
display_name: str = Form(...),
price: float = Form(...),
price_open: float = Form(...),
description: str = Form(""),
image_url: str = Form(""),
items: str = Form(...),
items_json: str = Form(...),
admin: User = Depends(get_current_admin)
):
cases = load_cases_config()
for case in cases:
if case['name'] == name:
if case['name'] == case_name:
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
try:
item_ids = json.loads(items)
parsed_items = json.loads(items_json)
except:
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
new_case = {
"name": name,
"name": case_name,
"display_name": display_name,
"price_open": price,
"items": item_ids,
"price_open": price_open,
"items": parsed_items,
"image_url": image_url,
"description": description
}
@@ -522,10 +611,11 @@ async def admin_create_case(
async def admin_update_case(
case_name: str,
display_name: str = Form(...),
price: float = Form(...),
price_open: float = Form(...),
description: str = Form(""),
image_url: str = Form(""),
items: str = Form(...),
items_json: str = Form(...),
new_name: str = Form(""),
admin: User = Depends(get_current_admin)
):
cases = load_cases_config()
@@ -533,18 +623,34 @@ async def admin_update_case(
for case in cases:
if case['name'] == case_name:
try:
item_ids = json.loads(items)
parsed_items = json.loads(items_json)
except:
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
target_name = new_name or case_name
# Проверяем конфликт имени при переименовании
if new_name and new_name != case_name:
for c in cases:
if c['name'] == new_name:
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
case['name'] = new_name
case['display_name'] = display_name
case['price_open'] = price
case['price_open'] = price_open
case['description'] = description
case['image_url'] = image_url
case['items'] = item_ids
case['items'] = parsed_items
save_cases_config(cases)
# Обновляем mainpage если переименован
if new_name and new_name != case_name:
mainpage = load_mainpage_config()
for section in mainpage:
section['cases'] = [new_name if c == case_name else c for c in section['cases']]
save_mainpage_config(mainpage)
return JSONResponse({
"success": True,
"case": case,
@@ -594,57 +700,90 @@ async def admin_search_items(
q: str = "",
min_price: float = 0,
max_price: float = 0,
limit: int = 200
limit: int = 200,
rarity_filter: str = "",
collection_filter: str = "",
type_filter: str = "",
weapon_filter: str = "",
sort_by: str = "",
sort_order: str = "asc",
include_subvariants: bool = False
):
"""Поиск предметов для добавления в кейс / апгрейда"""
"""Поиск предметов для добавления в кейс / апгрейда."""
from frontend import ALL_ITEMS
from backend import COLLECTIONS_INDEX
query = q.lower().strip()
results = []
seen_bases = set()
def accept_item(item, idx):
if not include_subvariants and item.get("is_subvariant"):
return False
if min_price > 0 and item.get("price_rub", 0) < min_price:
return False
if max_price > 0 and item.get("price_rub", 0) > max_price:
return False
if rarity_filter:
item_rarity = item.get("rarity", "").lower()
if rarity_filter.lower() not in item_rarity:
return False
if collection_filter:
item_coll = item.get("collection", "").lower()
if collection_filter.lower() not in item_coll:
return False
if type_filter:
item_type = item.get("type", "").lower()
if type_filter.lower() != item_type:
return False
if weapon_filter:
item_weapon = item.get("weapon", "").lower()
if weapon_filter.lower() != item_weapon:
return False
return True
if not query or len(query) < 2:
# Без поиска возвращаем разнообразные предметы со всего ассортимента
# Если есть ценовой фильтр — проходим по всем, иначе шагами
if min_price > 0 or max_price > 0:
for idx, item in enumerate(ALL_ITEMS):
if accept_item(item, idx):
item_id = item.get("_id", idx)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
def make_result(item, idx):
name = item.get("market_hash_name", "Unknown")
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
if dedup_key in seen_bases:
return None
seen_bases.add(dedup_key)
return {
"id": item.get("_id", idx),
"name": name,
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
})
"pattern": item.get("pattern", ""),
"collection": item.get("collection", ""),
"type": item.get("type", "Normal"),
}
if query and len(query) < 2:
return []
if not query or len(query) < 2:
if any([min_price > 0, max_price > 0, rarity_filter, collection_filter,
type_filter, weapon_filter]):
for idx, item in enumerate(ALL_ITEMS):
if not accept_item(item, idx):
continue
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
else:
step = max(1, len(ALL_ITEMS) // limit)
for idx in range(0, len(ALL_ITEMS), step):
if not accept_item(ALL_ITEMS[idx], idx):
continue
item = ALL_ITEMS[idx]
item_id = item.get("_id", idx)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
})
if len(results) >= 200:
if not accept_item(item, idx):
continue
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
else:
for idx, item in enumerate(ALL_ITEMS):
@@ -653,22 +792,111 @@ async def admin_search_items(
name = item.get("market_hash_name", "").lower()
weapon = item.get("weapon", "").lower()
pattern = item.get("pattern", "").lower()
coll = item.get("collection", "").lower()
if query in name or query in weapon or query in pattern:
item_id = item.get("_id", idx)
if query in name or query in weapon or query in pattern or query in coll:
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
# Сортировка
if sort_by in ("price_rub", "name", "rarity"):
reverse = sort_order == "desc"
if sort_by == "rarity":
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, "Contraband": 7
}
results.sort(key=lambda x: rarity_rank.get(x.get("rarity", ""), 0),
reverse=reverse)
else:
results.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
return results[:200]
@admin_router.get("/api/items/filters")
async def admin_items_filters():
"""Возвращает списки уникальных значений для фильтров"""
from frontend import ALL_ITEMS
weapons = set()
types = set()
for item in ALL_ITEMS:
if item.get("is_subvariant"):
continue
w = item.get("weapon", "")
if w:
weapons.add(w)
t = item.get("type", "")
if t:
types.add(t)
return {
"weapons": sorted(weapons),
"types": sorted(types),
}
@admin_router.get("/api/collections")
async def admin_list_collections():
"""Возвращает список всех коллекций для фильтра"""
from backend import COLLECTIONS_INDEX
return {"collections": sorted(COLLECTIONS_INDEX.keys())}
@admin_router.get("/api/items/by-collection")
async def admin_items_by_collection(
collection: str = "",
rarity_filter: str = "",
type_filter: str = "",
weapon_filter: str = "",
include_subvariants: bool = False
):
"""Возвращает предметы из указанной коллекции"""
from backend import COLLECTIONS_INDEX
from frontend import get_item
results = []
seen_bases = set()
if collection not in COLLECTIONS_INDEX:
return results
for idx in COLLECTIONS_INDEX[collection]:
item = get_item(idx)
if not item:
continue
if not include_subvariants and item.get("is_subvariant"):
continue
if rarity_filter:
if rarity_filter.lower() not in item.get("rarity", "").lower():
continue
if type_filter:
if type_filter.lower() != item.get("type", "").lower():
continue
if weapon_filter:
if weapon_filter.lower() != item.get("weapon", "").lower():
continue
name = item.get("market_hash_name", "Unknown")
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
if dedup_key in seen_bases:
continue
seen_bases.add(dedup_key)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
"id": item.get("_id", idx),
"name": name,
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
"pattern": item.get("pattern", ""),
"collection": item.get("collection", ""),
"type": item.get("type", "Normal"),
})
if len(results) >= 200:
break
return results[:200]
return results
@admin_router.post("/api/mainpage/section/add")
@@ -787,6 +1015,129 @@ async def admin_get_item(
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
# ========== API ПРОМОКОДЫ ==========
@admin_router.get("/api/promocodes")
async def admin_list_promocodes(
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
codes = db.query(PromoCode).order_by(desc(PromoCode.created_at)).limit(100).all()
return [{
"id": c.id,
"code": c.code,
"reward_type": c.reward_type,
"reward_amount": c.reward_amount,
"max_uses": c.max_uses,
"used_count": c.used_count,
"start_date": c.start_date.isoformat() if c.start_date else None,
"end_date": c.end_date.isoformat() if c.end_date else None,
"is_active": c.is_active,
} for c in codes]
@admin_router.post("/api/promocodes/create")
async def admin_create_promocode(
code: str = Form(...),
reward_type: str = Form(...),
reward_amount: float = Form(0),
reward_data: str = Form(""),
max_uses: int = Form(1),
end_date_str: str = Form(""),
is_active: bool = Form(True),
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
existing = db.query(PromoCode).filter(PromoCode.code == code).first()
if existing:
return JSONResponse(status_code=400, content={"error": "Промокод с таким кодом уже существует"})
end_date = None
if end_date_str:
try:
end_date = datetime.fromisoformat(end_date_str)
except:
pass
promo = PromoCode(
code=code,
reward_type=reward_type,
reward_amount=reward_amount,
reward_data=reward_data,
max_uses=max_uses,
end_date=end_date,
is_active=is_active,
created_by=admin.id,
)
db.add(promo)
db.commit()
return {"success": True, "message": f"Промокод '{code}' создан"}
@admin_router.post("/api/promocodes/toggle/{promo_id}")
async def admin_toggle_promocode(
promo_id: int,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first()
if not promo:
return JSONResponse(status_code=404, content={"error": "Промокод не найден"})
promo.is_active = not promo.is_active
db.commit()
return {"success": True, "is_active": promo.is_active}
@admin_router.post("/api/promocodes/delete/{promo_id}")
async def admin_delete_promocode(
promo_id: int,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first()
if not promo:
return JSONResponse(status_code=404, content={"error": "Промокод не найден"})
db.delete(promo)
db.commit()
return {"success": True, "message": "Промокод удален"}
@admin_router.get("/api/promo-phase")
async def admin_get_promo_phase(
admin: User = Depends(get_current_admin)
):
"""Информация о текущей промо-фазе (для админки)"""
from promo_phase import get_phase_debug_info
return get_phase_debug_info()
# ========== API ТРАНЗАКЦИИ ==========
@admin_router.get("/api/transactions")
async def admin_list_transactions(
user_id: Optional[int] = None,
limit: int = 100,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
query = db.query(TransactionLog)
if user_id:
query = query.filter(TransactionLog.user_id == user_id)
txns = query.order_by(desc(TransactionLog.created_at)).limit(limit).all()
return [{
"id": t.id,
"user_id": t.user_id,
"tx_type": t.tx_type,
"amount": t.amount,
"fee": t.fee,
"promo_code": t.promo_code,
"item_name": t.item_name,
"details": t.details,
"created_at": t.created_at.isoformat(),
} for t in txns]
@admin_router.post("/api/reset-password/{user_id}")
async def admin_reset_password(
user_id: int,
+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
+107
View File
@@ -0,0 +1,107 @@
"""
BankAPI внешний сервис для управления "картой" пользователя.
Сейчас заглушка (mock), архитектура под настоящий REST.
"""
import random
from datetime import datetime, timedelta
from typing import Optional
class BankAPIClient:
"""
Клиент для взаимодействия с Bank API.
В будущем HTTP-клиент к внешнему сервису.
Сейчас in-memory заглушка.
"""
def __init__(self, base_url: str = "http://bank-api:8000"):
self.base_url = base_url
# mock: user_id -> {balance, transactions}
self._mock_balances: dict[int, float] = {}
self._mock_txns: dict[int, list] = {}
self._initial_balance = 1000.0
def _ensure_user(self, user_id: int):
if user_id not in self._mock_balances:
self._mock_balances[user_id] = self._initial_balance
self._mock_txns[user_id] = []
async def get_balance(self, user_id: int) -> float:
"""Сколько денег на карте пользователя"""
self._ensure_user(user_id)
return self._mock_balances[user_id]
async def transfer_to_site(self, user_id: int, amount: float,
promo_code: Optional[str] = None) -> dict:
"""
Перевод с карты на баланс сайта.
Возвращает: {success, amount, new_card_balance, promo_effect}
"""
self._ensure_user(user_id)
if self._mock_balances[user_id] < amount:
return {"success": False, "error": "Недостаточно средств на карте"}
self._mock_balances[user_id] -= amount
self._mock_txns[user_id].append({
"type": "card_to_site",
"amount": amount,
"promo": promo_code,
"timestamp": datetime.utcnow().isoformat()
})
return {
"success": True,
"amount": amount,
"new_card_balance": self._mock_balances[user_id],
"promo_effect": None
}
async def withdraw_to_card(self, user_id: int, amount: float) -> dict:
"""
Вывод с баланса сайта на карту.
Возвращает: {success, amount, new_card_balance, fee}
"""
self._ensure_user(user_id)
fee = round(amount * 0.10, 2) # 10% комиссия
net = round(amount - fee, 2)
self._mock_balances[user_id] += net
self._mock_txns[user_id].append({
"type": "site_to_card",
"gross": amount,
"fee": fee,
"net": net,
"timestamp": datetime.utcnow().isoformat()
})
return {
"success": True,
"amount": amount,
"fee": fee,
"net": net,
"new_card_balance": self._mock_balances[user_id]
}
async def validate_promo(self, user_id: int, promo_code: str) -> dict:
"""
Проверяет промокод. Возвращает его эффект для перевода card->site.
"""
# Заглушка — реальная логика будет в PromoCode сервисе
return {
"valid": True,
"promo_type": "card_to_site",
"amount": 0,
"luck_boost": 0,
"ceiling_boost": 0
}
async def get_transaction_history(self, user_id: int,
limit: int = 50) -> list:
"""История транзакций по карте"""
self._ensure_user(user_id)
return self._mock_txns[user_id][-limit:]
# Singleton
bank_client = BankAPIClient()
+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
+1891 -1
View File
File diff suppressed because it is too large Load Diff
+227 -73
View File
@@ -1,19 +1,59 @@
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, 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()
if is_sqlite:
with engine.connect() as conn:
conn.execute(text("PRAGMA journal_mode=WAL"))
conn.execute(text("PRAGMA synchronous=NORMAL"))
conn.execute(text("PRAGMA cache_size=-64000"))
conn.execute(text("PRAGMA busy_timeout=5000"))
conn.commit()
# ─── Async engine (PostgreSQL production) ───────────────────────────────────
async_engine = create_async_engine(
ASYNC_DATABASE_URL,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
echo=False,
)
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)
# ─── Models ─────────────────────────────────────────────────────────────────
class User(Base):
__tablename__ = "users"
__table_args__ = (
Index("ix_users_username", "username"),
Index("ix_users_created_at", "created_at"),
)
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
hashed_password = Column(String(200), nullable=False)
balance = Column(Float, default=0.0)
@@ -21,6 +61,10 @@ class User(Base):
is_banned = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True)
card_balance_cached = Column(Float, default=1000.0)
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")
case_openings = relationship("CaseOpening", back_populates="user", cascade="all, delete-orphan")
@@ -28,34 +72,34 @@ class User(Base):
rpu_settings = relationship("UserRPU", back_populates="user", uselist=False, cascade="all, delete-orphan")
upgrades = relationship("Upgrade", back_populates="user", cascade="all, delete-orphan")
upgrade_rpu = relationship("UpgradeRPU", back_populates="user", uselist=False, cascade="all, delete-orphan")
transactions = relationship("TransactionLog", back_populates="user", cascade="all, delete-orphan")
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))
@@ -64,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)
@@ -84,46 +127,106 @@ 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_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="")
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)
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)
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="")
max_uses = Column(Integer, default=1)
used_count = Column(Integer, default=0)
start_date = Column(DateTime, default=datetime.utcnow)
end_date = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
created_by = Column(Integer, nullable=True)
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)
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)
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)
@@ -131,87 +234,138 @@ class CaseOpening(Base):
rarity = Column(String(50))
float_value = Column(Float)
opened_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="case_openings")
class Contract(Base):
__tablename__ = "contracts"
__table_args__ = (
Index("ix_contracts_user_id", "user_id"),
Index("ix_contracts_user_created", "user_id", "created_at"),
)
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
input_item_ids = Column(Text, nullable=False) # JSON список ID из инвентаря
input_item_ids = Column(Text, nullable=False)
output_item_id = Column(Integer, nullable=False)
output_item_name = Column(String(200))
output_float = Column(Float)
probability = Column(Float)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="contracts")
class Achievement(Base):
__tablename__ = "achievements"
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True)
name = Column(String(100), unique=True, nullable=False)
title = Column(String(200), nullable=False)
description = Column(String(500))
icon = Column(String(50), default="🏆")
category = Column(String(50), default="general") # cases, contracts, upgrade, social, general
requirement_type = Column(String(50), nullable=False) # open_cases, contracts, upgrade_wins, total_spent, valuable_item, etc.
requirement_value = Column(Integer, nullable=False) # how many
reward_amount = Column(Float, default=0.0) # bonus rubles
category = Column(String(50), default="general")
requirement_type = Column(String(50), nullable=False)
requirement_value = Column(Integer, nullable=False)
reward_amount = Column(Float, default=0.0)
sort_order = Column(Integer, default=0)
hidden = Column(Boolean, default=False) # секретные достижения
hidden = Column(Boolean, default=False)
user_achievements = relationship("UserAchievement", back_populates="achievement")
class UserAchievement(Base):
__tablename__ = "user_achievements"
__table_args__ = (
Index("ix_user_achievements_user", "user_id"),
Index("ix_user_achievements_ach", "achievement_id"),
Index("ix_user_achievements_both", "user_id", "achievement_id", unique=True),
)
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False)
progress = Column(Integer, default=0) # текущий прогресс
progress = Column(Integer, default=0)
unlocked_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", backref="user_achievements")
achievement = relationship("Achievement", back_populates="user_achievements")
class ActivityFeed(Base):
__tablename__ = "activity_feed"
__table_args__ = (
Index("ix_activity_user_id", "user_id"),
Index("ix_activity_created", "created_at"),
)
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
username = Column(String(50), nullable=False)
activity_type = Column(String(50), nullable=False) # case_open, contract, upgrade, achievement, trade, crash
activity_type = Column(String(50), nullable=False)
message = Column(String(500), nullable=False)
data_json = Column(Text, default="{}") # дополнительные данные (item_name, price, etc.)
data_json = Column(Text, default="{}")
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", backref="activities")
Base.metadata.create_all(bind=engine)
# Миграция: добавляем колонку 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}")
# ─── Create tables ──────────────────────────────────────────────────────────
def init_sync_db():
Base.metadata.create_all(bind=engine)
_run_migrations(engine, is_sync=True)
def get_db():
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 as sa_inspect
inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn)
existing_tables = inspector.get_table_names()
for tbl_name in Base.metadata.tables:
if tbl_name not in existing_tables:
continue
model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns}
db_cols = {c["name"] for c in inspector.get_columns(tbl_name)}
missing = model_cols - db_cols
if missing:
print(f"[DB] Migrating {tbl_name}: adding {missing}")
except Exception as e:
print(f"[DB] Migration check: {e}")
# ─── 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)
+674 -382
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -12,18 +12,18 @@
"cases": [
"CS:GO Weapon Case",
"Revolution Case",
"Recoil Case"
"Recoil Case",
"Prisma 2 Case",
"Prisma Case",
"Fracture Case",
"Danger Zone Case"
]
},
{
"tab": "Новинки",
"cases": [
"allin1",
"Danger Zone Case",
"Prisma Case",
"Fracture Case",
"Prisma 2 Case",
"m9orbutterfly1"
"Revolvernoe"
]
},
{
+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()
+5
View File
@@ -0,0 +1,5 @@
{
"phase": "LUCKY",
"until": "2026-07-22T19:34:16.536290",
"started_at": "2026-07-22T18:22:18.082890"
}
+178
View File
@@ -0,0 +1,178 @@
"""
Promo Phase глобальное скрытое состояние "промо-фазы".
Влияет на то, какой эффект дают промокоды (везёт / не везёт / нейтрально).
Переключается случайно каждые 1-6 часов.
"""
import json
import random
from datetime import datetime, timedelta
from pathlib import Path
PHASE_FILE = Path("promo_phase.json")
PHASE_CONFIG = {
"LUCKY": {
"weight": 0.4,
"min_hours": 1,
"max_hours": 4,
"effect": "boost",
"boost_range": (0.15, 0.35), # +15-35% к luck_multiplier
"rare_chance_boost": 0.05, # +5% к шансу редкого
},
"UNLUCKY": {
"weight": 0.4,
"min_hours": 1,
"max_hours": 4,
"effect": "penalty",
"penalty_range": (0.10, 0.25), # -10-25% к luck_multiplier
"ceiling_penalty": 0.10, # -10% к потолку
},
"NEUTRAL": {
"weight": 0.2,
"min_hours": 1,
"max_hours": 3,
"effect": "none",
}
}
def load_phase():
"""Загружает текущую промо-фазу из файла"""
if PHASE_FILE.exists():
try:
with open(PHASE_FILE, 'r') as f:
return json.load(f)
except:
pass
return {
"phase": "NEUTRAL",
"until": datetime.utcnow().isoformat(),
"started_at": datetime.utcnow().isoformat()
}
def save_phase(state: dict):
"""Сохраняет промо-фазу"""
with open(PHASE_FILE, 'w') as f:
json.dump(state, f, indent=2)
def pick_next_phase() -> str:
"""Выбирает следующую фазу случайно, с учётом весов"""
phases = list(PHASE_CONFIG.keys())
weights = [PHASE_CONFIG[p]["weight"] for p in phases]
return random.choices(phases, weights=weights, k=1)[0]
def get_random_duration(phase: str) -> timedelta:
"""Случайная длительность фазы"""
config = PHASE_CONFIG.get(phase, PHASE_CONFIG["NEUTRAL"])
min_h = config["min_hours"]
max_h = config["max_hours"]
hours = random.uniform(min_h, max_h)
return timedelta(hours=hours)
def ensure_phase():
"""Проверяет и при необходимости переключает фазу"""
state = load_phase()
until = datetime.fromisoformat(state["until"])
if datetime.utcnow() >= until:
new_phase = pick_next_phase()
duration = get_random_duration(new_phase)
state["phase"] = new_phase
state["until"] = (datetime.utcnow() + duration).isoformat()
state["started_at"] = datetime.utcnow().isoformat()
save_phase(state)
return state["phase"]
def get_current_phase() -> str:
"""Возвращает текущую фазу"""
return ensure_phase()
def get_promo_effect_for_user(user_id: int, hot_score: float = 0.0,
withdrawal_ratio: float = 0.0,
is_cold: bool = False) -> dict:
"""
Возвращает индивидуальный эффект промо-фазы для пользователя.
Одинаковая фаза разный эффект в зависимости от hot/cold/withdrawal.
"""
phase = get_current_phase()
config = PHASE_CONFIG.get(phase, {})
effect_type = config.get("effect", "none")
result = {
"phase": phase,
"effect": effect_type,
"luck_modifier": 0.0, # additive к luck_multiplier
"ceiling_modifier": 0.0, # additive к ceiling
"rare_chance_modifier": 0.0, # additive к шансу редкого
"description": ""
}
if effect_type == "boost":
boost = random.uniform(*config["boost_range"])
# Холодные игроки получают БОЛЬШЕ буста (реактивация)
if is_cold:
boost *= 1.5
# Кто много вывел — получает МЕНЬШЕ
boost *= max(0.5, 1.0 - withdrawal_ratio * 0.5)
result["luck_modifier"] = boost
result["rare_chance_modifier"] = config.get("rare_chance_boost", 0)
result["description"] = "Промо-удача 🍀"
elif effect_type == "penalty":
penalty = random.uniform(*config["penalty_range"])
# Горячие (много потратили, 0 выводов) почти не чувствуют пенальти
if hot_score > 10000 and withdrawal_ratio < 0.1:
penalty *= 0.3
# Кто вывел много — получает ПОЛНУЮ открутку
if withdrawal_ratio > 0.5:
penalty *= 1.5
result["luck_modifier"] = -penalty
result["ceiling_modifier"] = -config.get("ceiling_penalty", 0)
result["description"] = "Промо-открутка 💀"
return result
def get_promo_phase_info() -> dict:
"""Возвращает инфо о фазе: phase, effect, next_switch, remaining_minutes."""
state = load_phase()
phase = state["phase"]
until = datetime.fromisoformat(state["until"])
config = PHASE_CONFIG.get(phase, {})
remaining = (until - datetime.utcnow()).total_seconds()
return {
"phase": phase,
"effect": config.get("effect", "none"),
"next_switch": until.isoformat(),
"remaining_minutes": max(0, int(remaining // 60)),
}
def get_phase_debug_info() -> dict:
"""Для админки — отладка промо-фаз"""
state = load_phase()
phase = state["phase"]
until = datetime.fromisoformat(state["until"])
started = datetime.fromisoformat(state["started_at"])
remaining = (until - datetime.utcnow()).total_seconds()
return {
"current_phase": phase,
"started_at": started.isoformat(),
"until": until.isoformat(),
"remaining_seconds": max(0, int(remaining)),
"config": PHASE_CONFIG.get(phase, {})
}
+352
View File
@@ -0,0 +1,352 @@
"""
Promo Service логика промокодов и депозитов.
"""
from datetime import datetime, timedelta
import random
from typing import Optional
from sqlalchemy.orm import Session
from database import User, PromoCode, TransactionLog
from bank_api import bank_client
from promo_phase import get_promo_effect_for_user, get_current_phase
DAILY_DEPOSIT_LIMIT = 999999999.0 # без лимита
# ── Авто-генерация промокодов ──
PROMO_WORDS = ["storm", "glitch", "frost", "blaze", "cipher", "nexus", "phantom", "prism"]
PROMO_PERCENTS = [10, 15, 20, 21, 24, 25]
# Хранилище текущего авто-промокода (in-memory кэш)
_current_auto_promo: Optional[dict] = None
def generate_auto_promo_code() -> str:
"""Генерирует промокод формата слово-процент, например 'storm-21'"""
word = random.choice(PROMO_WORDS)
pct = random.choice(PROMO_PERCENTS)
return f"{word}-{pct}"
def get_or_create_auto_promo(db: Session) -> PromoCode:
"""
Возвращает активный авто-сгенерированный промокод.
Если его нет или он истёк (>1ч) создаёт новый.
"""
global _current_auto_promo
now = datetime.utcnow()
# Ищем существующий авто-промокод, созданный <1 часа назад
candidates = db.query(PromoCode).filter(
PromoCode.reward_type == "card_to_site",
PromoCode.is_active == True,
PromoCode.created_by == -1 # маркер авто-генерации
).order_by(PromoCode.created_at.desc()).limit(1).all()
if candidates:
promo = candidates[0]
age = (now - promo.created_at).total_seconds()
if age < 3600: # меньше часа — живой
_current_auto_promo = {
"code": promo.code,
"reward_amount": promo.reward_amount,
"created_at": promo.created_at.isoformat(),
}
return promo
# Создаём новый
code = generate_auto_promo_code()
pct = int(code.split("-")[1])
reward = pct # процент от daily лимита как сумма бонуса
new_promo = PromoCode(
code=code,
reward_type="card_to_site",
reward_amount=reward,
reward_data="auto",
max_uses=999,
used_count=0,
start_date=now,
end_date=now + timedelta(hours=1),
is_active=True,
created_by=-1,
)
db.add(new_promo)
db.commit()
_current_auto_promo = {
"code": new_promo.code,
"reward_amount": new_promo.reward_amount,
"created_at": new_promo.created_at.isoformat(),
}
return new_promo
def get_current_auto_promo(db: Session) -> Optional[dict]:
"""Возвращает информацию о текущем авто-промокоде."""
promo = get_or_create_auto_promo(db)
now = datetime.utcnow()
age = (now - promo.created_at).total_seconds()
remaining = max(0, 3600 - age)
return {
"code": promo.code,
"reward_amount": promo.reward_amount,
"created_at": promo.created_at.isoformat(),
"expires_in_seconds": int(remaining),
"used_count": promo.used_count,
}
def validate_promo_code(db: Session, code: str) -> Optional[dict]:
"""Проверяет промокод, возвращает данные или None"""
promo = db.query(PromoCode).filter(
PromoCode.code == code,
PromoCode.is_active == True
).first()
if not promo:
return None
now = datetime.utcnow()
if promo.end_date and now > promo.end_date:
return {"error": "Срок действия промокода истёк"}
if promo.start_date and now < promo.start_date:
return {"error": "Промокод ещё не активен"}
if promo.max_uses > 0 and promo.used_count >= promo.max_uses:
return {"error": "Промокод уже использован максимальное количество раз"}
return {
"id": promo.id,
"code": promo.code,
"reward_type": promo.reward_type,
"reward_amount": promo.reward_amount,
"reward_data": promo.reward_data,
}
async def activate_promo_code(db: Session, user_id: int, code: str, amount: float = 0) -> dict:
"""
Активирует промокод.
Типы: card_to_site, luck_boost, ceiling_boost, free_case, reset_streak
"""
promo_data = validate_promo_code(db, code)
if not promo_data:
return {"success": False, "error": "Промокод не найден"}
if "error" in promo_data:
return {"success": False, "error": promo_data["error"]}
promo = db.query(PromoCode).filter(PromoCode.id == promo_data["id"]).first()
if not promo:
return {"success": False, "error": "Ошибка загрузки промокода"}
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"success": False, "error": "Пользователь не найден"}
rpu = user.rpu_settings
result = {"success": True, "code": code, "type": promo.reward_type}
promo.used_count += 1
# Записываем транзакцию
txn = TransactionLog(
user_id=user_id,
tx_type="promo_bonus",
amount=0,
promo_code=code,
details=f"Промокод: {code} ({promo.reward_type})"
)
db.add(txn)
if promo.reward_type == "card_to_site":
if amount <= 0:
return {"success": False, "error": "Укажите сумму депозита"}
pct = promo.reward_amount # например 24 → 24%
bonus = round(amount * pct / 100, 2)
total = round(amount + bonus, 2)
# Проверяем баланс карты (требуем только сумму депозита)
bank_balance = await bank_client.get_balance(user_id)
if bank_balance < amount:
return {"success": False, "error": f"Недостаточно средств на карте. Нужно: {amount}"}
# Списываем с карты только депозит
await bank_client.transfer_to_site(user_id, amount, promo_code=code)
# Зачисляем на сайт (депозит + бонус от платформы)
user.balance += total
user.total_deposited += amount
txn.amount = total
txn.details = f"Депозит {amount}₽ + бонус {pct}% ({bonus}₽) по промокоду {code}"
result["amount"] = total
result["deposit"] = amount
result["bonus"] = bonus
result["new_balance"] = user.balance
elif promo.reward_type == "luck_boost":
boost = promo.reward_amount / 100.0 # например 20 → 0.2
if rpu:
rpu.luck_multiplier = min(3.0, rpu.luck_multiplier + boost)
result["boost"] = boost
elif promo.reward_type == "ceiling_boost":
boost_mult = promo.reward_amount / 100.0 + 1.0 # 50 → 1.5
if rpu:
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * boost_mult)
result["ceiling_boost"] = boost_mult
elif promo.reward_type == "free_case":
# Бесплатное открытие — просто отмечаем в результате
result["free_case"] = promo.reward_data or ""
elif promo.reward_type == "reset_streak":
if rpu:
rpu.current_streak = 0
rpu.consecutive_loss_count = 0
rpu.consecutive_loss_value = 0.0
result["reset_streak"] = True
db.commit()
return result
async def direct_deposit(db: Session, user_id: int, amount: float, promo_code: str = "") -> dict:
"""
Прямой депозит с карты на сайт.
Если указан промокод применяет бонус поверх депозита.
"""
if amount <= 0:
return {"success": False, "error": "Сумма должна быть положительной"}
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"success": False, "error": "Пользователь не найден"}
# Проверка промокода (если указан)
bonus = 0
promo_pct = 0
if promo_code:
promo_data = validate_promo_code(db, promo_code)
if promo_data and "error" not in promo_data:
promo = db.query(PromoCode).filter(PromoCode.id == promo_data["id"]).first()
if promo and promo.reward_type == "card_to_site":
promo_pct = promo.reward_amount
bonus = round(amount * promo_pct / 100, 2)
promo.used_count += 1
total = round(amount + bonus, 2)
# Проверка дневного лимита
today = datetime.utcnow().date()
daily_total = db.query(TransactionLog).filter(
TransactionLog.user_id == user_id,
TransactionLog.tx_type == "card_to_site",
).all()
today_deposits = sum(
t.amount for t in daily_total
if t.created_at.date() == today
)
if today_deposits + amount > DAILY_DEPOSIT_LIMIT:
remaining = max(0, DAILY_DEPOSIT_LIMIT - today_deposits)
return {
"success": False,
"error": f"Дневной лимит депозита: {remaining:.2f}"
}
# Проверяем баланс карты
bank_balance = await bank_client.get_balance(user_id)
if bank_balance < amount:
return {"success": False, "error": "Недостаточно средств на карте"}
# Списываем с карты (только депозит, бонус от платформы)
await bank_client.transfer_to_site(user_id, amount)
# Зачисляем на сайт
user.balance += total
user.total_deposited += amount
user.last_deposit_date = datetime.utcnow()
details = f"Прямой депозит: +{amount}"
if bonus > 0:
details += f" + бонус {promo_pct}% ({bonus}₽)"
txn = TransactionLog(
user_id=user_id,
tx_type="card_to_site",
amount=total,
promo_code=promo_code if promo_code else None,
details=details
)
db.add(txn)
db.commit()
return {
"success": True,
"amount": total,
"deposit": amount,
"bonus": bonus,
"new_balance": user.balance,
"daily_remaining": max(0, DAILY_DEPOSIT_LIMIT - today_deposits - amount)
}
async def withdraw_item(db: Session, user_id: int, inventory_item_id: int) -> dict:
"""
Вывод скина: продажа предмета из инвентаря зачисление на карту (с комиссией).
"""
from database import InventoryItem
item = db.query(InventoryItem).filter(
InventoryItem.id == inventory_item_id,
InventoryItem.user_id == user_id
).first()
if not item:
return {"success": False, "error": "Предмет не найден в инвентаре"}
price = item.price_rub or 0
if price <= 0:
return {"success": False, "error": "Предмет не имеет цены"}
user = db.query(User).filter(User.id == user_id).first()
result = await bank_client.withdraw_to_card(user_id, price)
if not result["success"]:
return {"success": False, "error": "Ошибка вывода"}
# Обновляем статистику пользователя
user.total_withdrawn += result["net"] # net = price - fee
db.delete(item)
txn = TransactionLog(
user_id=user_id,
tx_type="site_to_card",
amount=price,
fee=result["fee"],
item_name=item.market_hash_name,
inventory_item_id=inventory_item_id,
details=f"Вывод: {item.market_hash_name} за {price}₽ (комиссия {result['fee']}₽)"
)
db.add(txn)
db.commit()
from rpu import check_withdrawal_penalty
check_withdrawal_penalty(db, user_id)
return {
"success": True,
"item_name": item.market_hash_name,
"gross": price,
"fee": result["fee"],
"net": result["net"],
"new_card_balance": result["new_card_balance"]
}
+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
+450 -132
View File
@@ -1,23 +1,20 @@
import json
import random
from datetime import datetime
from typing import Dict, Optional
from sqlalchemy.orm import Session
from database import User, UserRPU
import math
from datetime import datetime, date
from typing import Dict, Optional, Tuple
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
}
# Маппинг редкостей на поля в UserRPU
RARITY_TO_FIELD = {
"Consumer Grade": "consumer_multiplier",
"Industrial Grade": "industrial_multiplier",
@@ -30,163 +27,484 @@ RARITY_TO_FIELD = {
"Extraordinary": "rare_special_multiplier"
}
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
}
def get_user_rpu(db: Session, user_id: int) -> UserRPU:
"""Получает или создает настройки РПУ для пользователя"""
rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first()
CEILING_BASE_MULTIPLIER = 3.0
CEILING_MIN = 1000.0
CEILING_SESSION_HISTORY = {0: 5.0, 1: 4.0, 2: 3.5, -1: 3.0}
BUDGET_MAX = 100.0
BUDGET_CONSUMPTION_RATE = 0.001
BUDGET_REGEN_PER_OPEN = 0.5
BUDGET_LOW_THRESHOLD = 20.0
COMEBACK_TRIGGER_COUNT = 5
COMEBACK_TRIGGER_VALUE_RATIO = 0.3
COMEBACK_OPENINGS = 5
COMEBACK_MULTIPLIER_MAX = 3.0
HOT_THRESHOLD_SPENT = 10000
HOT_THRESHOLD_WITHDRAWAL = 0.1
COLD_DAYS_INACTIVE = 7
COLD_SPENT_PER_DAY = 100
_BATCH_MODE = False
_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):
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):
if _BATCH_MODE:
_BATCH_WEIGHTS_CACHE[user_id] = weights
def _batch_cache_clear(user_id: int = None):
if user_id is not None:
_BATCH_WEIGHTS_CACHE.pop(user_id, None)
else:
_BATCH_WEIGHTS_CACHE.clear()
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)
db.commit()
db.refresh(rpu)
await _rpu_commit_or_flush_async(db)
await db.refresh(rpu)
return rpu
def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
"""
Получает веса редкостей с учетом РПУ пользователя.
НИЗКИЙ % РПУ = ПОВЫШЕННЫЕ шансы на редкое (везёт)
ВЫСОКИЙ % РПУ = ПОНИЖЕННЫЕ шансы на редкое (не везёт)
"""
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)
# Вычисляем общий уровень РПУ (0-100%)
rpu_level = calculate_rpu_level(rpu)
# Переворачиваем: низкий РПУ = высокий множитель для редких
# При 0% РПУ множитель для редких = 2.0 (очень везёт)
# При 50% РПУ множитель = 1.0 (обычно)
# При 100% РПУ множитель = 0.1 (совсем не везёт)
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)
await _rpu_commit_or_flush_async(db)
def get_budget_factor(rpu: UserRPU) -> float:
if rpu.luck_budget >= BUDGET_LOW_THRESHOLD:
return 1.0
return max(0.3, rpu.luck_budget / BUDGET_LOW_THRESHOLD)
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
await _rpu_commit_or_flush_async(db)
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
await _rpu_commit_or_flush_async(db)
return mult
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 = 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
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()
await _rpu_commit_or_flush_async(db)
return is_hot, is_cold, wd_ratio
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 = 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)
await _rpu_commit_or_flush_async(db)
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 "💀 Проклятый"
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
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 = await apply_comeback(db, user_id)
budget_factor = get_budget_factor(rpu)
ceiling_factor = await get_ceiling_luck_factor(db, user_id)
adjusted = {}
for rarity, base_weight in BASE_RARITY_WEIGHTS.items():
field = RARITY_TO_FIELD.get(rarity)
base_multiplier = getattr(rpu, field, 1.0) if field else 1.0
rarity_index = RARITY_RANK.get(rarity, 0)
# Определяем "редкость" предмета (чем реже, тем больше индекс)
rarity_index = 0
if "Industrial" in rarity:
rarity_index = 1
elif "Mil-Spec" in rarity:
rarity_index = 2
elif "Restricted" in rarity:
rarity_index = 3
elif "Classified" in rarity:
rarity_index = 4
elif "Covert" in rarity:
rarity_index = 5
elif "Rare Special" in rarity or "Extraordinary" in rarity:
rarity_index = 6
# Чем выше РПУ, тем сильнее режем шансы на редкое
# Чем ниже РПУ, тем сильнее повышаем шансы на редкое
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_multiplier = base_multiplier * rpu.luck_multiplier * luck_factor
adjusted[rarity] = base_weight * final_multiplier
final = base_multiplier * rpu.luck_multiplier * luck_factor
final *= (1.0 + promo["luck_modifier"])
final *= budget_factor
final *= comeback_mult
final *= ceiling_factor
if rarity_index >= 3:
final *= (1.0 + promo.get("rare_chance_modifier", 0))
final = max(0.05, min(5.0, final))
adjusted[rarity] = base_weight * final
_batch_cache_set(user_id, adjusted)
return adjusted
def calculate_rpu_level(rpu: UserRPU) -> float:
"""
Вычисляет общий уровень РПУ (0-100%).
0% = максимальное везение
100% = максимальное невезение
"""
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)
# Преобразуем в проценты: 0.1x = 100% РПУ (не везёт), 3.0x = 0% РПУ (везёт)
# 1.0x = 50% РПУ
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:
avg_spent_per_case = rpu.total_spent / rpu.total_opened
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']:
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']:
setattr(rpu, r, max(0.3, getattr(rpu, r) - penalty * 0.5))
# Если пользователь много тратит и мало получает - снижаем РПУ (даём везение)
if rpu.total_spent > 10000 and rpu.total_opened > 20:
# Снижаем РПУ на 5-15%
reduction = min(0.15, rpu.total_spent / 100000)
rpu.luck_multiplier = min(2.0, rpu.luck_multiplier + reduction)
# Если пользователь часто открывает кейсы - немного снижаем РПУ
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']:
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)
# Ограничиваем множители
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()
db.commit()
await _rpu_commit_or_flush_async(db)
def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1):
"""Обновляет статистику РПУ после открытия кейсов"""
rpu = get_user_rpu(db, user_id)
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
db.commit()
rpu.total_value_received += received_value
rpu.session_spent += spent
rpu.session_won += received_value
if is_rare or received_value > spent * 3:
rpu.current_streak = min(20, rpu.current_streak + 1)
else:
rpu.current_streak = max(-20, rpu.current_streak - 1)
if rpu.current_streak > rpu.best_streak:
rpu.best_streak = rpu.current_streak
if rpu.current_streak < rpu.worst_streak:
rpu.worst_streak = rpu.current_streak
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)
if user and is_rare and received_value > 0:
if await check_ceiling_breach(db, user_id, received_value, spent):
await apply_ceiling_breach(db, user_id, received_value)
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)
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 = 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 = await analyze_hot_cold(db, user_id)
session_count = max(0, (datetime.utcnow() - user.created_at).days) if user.created_at else 0
return {
"rpu_level": round(calculate_rpu_level(rpu)),
"description": get_rpu_description(calculate_rpu_level(rpu)),
"luck_multiplier": rpu.luck_multiplier,
"luck_budget": round(rpu.luck_budget, 1),
"ceiling": round(ceiling, 2),
"ceiling_multiplier": round(rpu.ceiling_multiplier, 2),
"ceiling_breach_total": rpu.ceiling_break_count,
"ceiling_breach_session": rpu.ceiling_break_session,
"total_value": round(total_value, 2),
"balance": round(user.balance, 2),
"inventory_value": round(inv_value, 2),
"total_deposited": round(user.total_deposited, 2),
"total_withdrawn": round(user.total_withdrawn, 2),
"session": {
"number": session_count,
"spent": round(rpu.session_spent, 2),
"won": round(rpu.session_won, 2),
},
"comeback": {
"active": rpu.comeback_active,
"openings_left": rpu.comeback_openings_left,
"multiplier": round(rpu.comeback_multiplier, 2),
},
"streak": {
"current": rpu.current_streak,
"best": rpu.best_streak,
"worst": rpu.worst_streak,
},
"hot_cold": {
"is_hot": is_hot,
"is_cold": is_cold,
"hot_score": round(rpu.hot_score, 2),
"withdrawal_ratio": round(wd_ratio, 3),
},
"promo_phase": get_current_phase(),
}
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
+5 -4
View File
@@ -14,9 +14,10 @@ async function refreshBalance() {
const res = await fetch('/web/api/user/balance');
const data = await res.json();
if (data.success) {
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.balance)}`;
});
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) {}
}
@@ -140,7 +141,7 @@ function showAchievementPopup(title, reward) {
}
document.getElementById('globalPopupTitle').textContent = title;
document.getElementById('globalPopupReward').textContent = reward ? `+${reward}` : '';
document.getElementById('globalPopupReward').textContent = '';
popup.style.display = 'block';
if (window.SoundManager) SoundManager.achievement();
+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;}};})();
+88 -49
View File
@@ -1,8 +1,9 @@
// Sound effects system using Web Audio API - no external files needed
const SoundManager = {
_ctx: null,
_enabled: true,
_volume: 0.3,
_activeNodes: new Set(),
_timeouts: [],
init() {
try {
@@ -25,10 +26,28 @@ const SoundManager = {
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;
@@ -39,12 +58,13 @@ const SoundManager = {
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);
@@ -59,91 +79,113 @@ const SoundManager = {
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() {
// Descending sweep with click
this._play(800, 0.1, 'square', 0.3);
setTimeout(() => this._play(400, 0.15, 'sawtooth', 0.2), 80);
setTimeout(() => this._play(200, 0.2, 'sine', 0.15), 180);
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() {
// Ascending bright chime for rare items
this._play(523, 0.15, 'sine', 0.3);
setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 100);
setTimeout(() => this._play(784, 0.15, 'sine', 0.3), 200);
setTimeout(() => this._play(1047, 0.3, 'sine', 0.4), 300);
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() {
// Epic sound for covert/knife
this._play(392, 0.2, 'sawtooth', 0.3);
setTimeout(() => this._play(523, 0.2, 'sawtooth', 0.3), 150);
setTimeout(() => this._play(659, 0.2, 'sawtooth', 0.3), 300);
setTimeout(() => this._play(784, 0.4, 'sine', 0.5), 450);
setTimeout(() => this._play(1047, 0.6, 'sine', 0.6), 600);
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() {
// Ratcheting sound for slot reels
for (let i = 0; i < 8; i++) {
setTimeout(() => {
this._delay(() => {
this._play(300 + Math.random() * 400, 0.05, 'square', 0.15);
}, i * 80);
}
},
slotMatch() {
// Win jingle
this._play(523, 0.1, 'sine', 0.3);
setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 100);
setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200);
setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 300);
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() {
// Mechanical sound
this._play(150, 0.3, 'sawtooth', 0.2);
setTimeout(() => this._play(200, 0.2, 'square', 0.2), 200);
setTimeout(() => this._play(250, 0.1, 'square', 0.15), 350);
this._delay(() => this._play(200, 0.2, 'square', 0.2), 200);
this._delay(() => this._play(250, 0.1, 'square', 0.15), 350);
},
contractResult() {
// Rising success
this._play(400, 0.15, 'sine', 0.3);
setTimeout(() => this._play(600, 0.15, 'sine', 0.3), 120);
setTimeout(() => this._play(800, 0.3, 'sine', 0.4), 240);
this._delay(() => this._play(600, 0.15, 'sine', 0.3), 120);
this._delay(() => this._play(800, 0.3, 'sine', 0.4), 240);
},
wheelSpin() {
// Ticking as wheel rotates
this._play(200, 0.03, 'square', 0.1);
this._play(600 + Math.random() * 400, 0.04, 'triangle', 0.12);
this._play(200, 0.02, 'square', 0.06);
},
wheelWin() {
// Celebration
this._play(523, 0.15, 'sine', 0.3);
setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 120);
setTimeout(() => this._play(784, 0.2, 'sine', 0.35), 240);
setTimeout(() => this._play(1047, 0.5, 'sine', 0.5), 360);
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() {
// Sad trombone
this._play(400, 0.2, 'sine', 0.2);
setTimeout(() => this._play(350, 0.2, 'sine', 0.2), 150);
setTimeout(() => this._play(300, 0.3, 'sine', 0.15), 300);
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() {
// Achievement unlock
this._play(659, 0.1, 'sine', 0.3);
setTimeout(() => this._play(523, 0.1, 'sine', 0.3), 100);
setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200);
setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 300);
setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 400);
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() {
@@ -155,17 +197,15 @@ const SoundManager = {
},
crashCrashed() {
// Explosion
this._noise(0.3, 0.6);
this._play(80, 0.5, 'sawtooth', 0.4);
setTimeout(() => this._play(50, 0.8, 'sine', 0.3), 100);
this._delay(() => this._play(50, 0.8, 'sine', 0.3), 100);
},
crashCashout() {
// Cashout success
this._play(600, 0.1, 'sine', 0.3);
setTimeout(() => this._play(800, 0.1, 'sine', 0.3), 80);
setTimeout(() => this._play(1000, 0.2, 'sine', 0.4), 160);
this._delay(() => this._play(800, 0.1, 'sine', 0.3), 80);
this._delay(() => this._play(1000, 0.2, 'sine', 0.4), 160);
},
click() {
@@ -181,5 +221,4 @@ const SoundManager = {
}
};
// Auto-init
document.addEventListener('DOMContentLoaded', () => SoundManager.init());
+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());
+75 -8
View File
@@ -140,6 +140,56 @@
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
/* Иммерсивные эффекты для ленты */
.asi-effect {
position: relative;
overflow: hidden;
}
.asi-effect::before {
content: attr(data-effect-text);
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
font-size: 2.5rem;
font-weight: 900;
color: currentColor;
opacity: 0.06;
pointer-events: none;
white-space: nowrap;
line-height: 1;
}
.asi-effect-glow {
box-shadow: 0 0 15px 2px rgba(255, 200, 50, 0.15), inset 0 0 20px rgba(255, 200, 50, 0.05);
animation: glowPulse 2s ease-in-out infinite;
}
@keyframes glowPulse {
0%,100% { box-shadow: 0 0 15px 2px rgba(255,200,50,0.15), inset 0 0 20px rgba(255,200,50,0.05); }
50% { box-shadow: 0 0 25px 5px rgba(255,200,50,0.25), inset 0 0 30px rgba(255,200,50,0.08); }
}
.asi-effect-shimmer {
position: absolute;
top: 0; left: -100%;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.07), transparent);
animation: shimmer 3s ease-in-out infinite;
pointer-events: none;
}
@keyframes shimmer {
0% { left: -100%; }
50% { left: 200%; }
100% { left: -100%; }
}
.asi-effect-badge {
display: inline-block;
font-size: 0.6rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-top: 0.15rem;
}
.asi-effect-badge.upgrade { background: rgba(139,92,246,0.25); color: #a78bfa; }
.asi-effect-badge.drop { background: rgba(251,191,36,0.25); color: #fbbf24; }
.has-activity-sidebar {
padding-left: 260px;
transition: padding-left 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
@@ -157,9 +207,6 @@
<div class="activity-sidebar" id="activitySidebar">
<div class="activity-sidebar-header">
<h3><span class="live-dot"></span> Лента</h3>
<span style="font-size:0.7rem;color:var(--text-secondary);display:flex;align-items:center;gap:0.25rem;">
<span id="onlineCount">0</span> 👤
</span>
<button class="activity-sidebar-close" onclick="toggleActivitySidebar()" title="Скрыть"></button>
</div>
<div class="activity-sidebar-list" id="activitySidebarList">
@@ -198,11 +245,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
WS.on('online_count', (data) => {
const el = document.getElementById('onlineCount');
if (el) el.textContent = data.online || 0;
});
WS.on('activity', (data) => {
const act = data.activity;
if (!act || !list) return;
@@ -241,9 +283,33 @@ function createActivityItem(act) {
const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, '');
const rarityColor = ACTIVITY_RARITY_COLORS[raritySlug] || '';
// ── Эффект ──
const effect = act.data && act.data.effect;
let effectBadgeHtml = '';
if (effect) {
const isUpgrade = effect.startsWith('upgrade:');
const isDrop = effect.startsWith('drop:');
const label = effect.replace('upgrade:', '').replace('drop:', '');
const cls = isUpgrade ? 'upgrade' : 'drop';
effectBadgeHtml = `<span class="asi-effect-badge ${cls}">${label}</span>`;
item.classList.add('asi-effect');
item.dataset.effectText = label;
if (isDrop) {
item.classList.add('asi-effect-glow');
const sh = document.createElement('div');
sh.className = 'asi-effect-shimmer';
item.appendChild(sh);
}
if (rarityColor) {
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.12)`;
item.style.borderLeft = `3px solid ${rarityColor}`;
}
} else {
if (rarityColor) {
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
}
}
item.addEventListener('click', () => window.location.href = `/profiles/${act.user_id}`);
@@ -260,6 +326,7 @@ function createActivityItem(act) {
<span class="asi-username">${escapeHtml(act.username)}</span>
<span>${escapeHtml(act.message)}</span>
<div class="asi-time">${timeStr}</div>
${effectBadgeHtml}
</div>
${imgHtml}
`;
+5 -8
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;
@@ -257,9 +257,6 @@
{% if ach.hidden %}<div class="secret-badge">🤫 Секрет</div>{% endif %}
<div class="achievement-title">{{ ach.title }}</div>
<div class="achievement-desc">{{ ach.description }}</div>
{% if ach.reward_amount > 0 %}
<div class="achievement-reward">+{{ ach.reward_amount|money }} ₽</div>
{% endif %}
<div class="achievement-progress">
<div class="progress-bar">
<div class="progress-fill {{ 'complete' if ach.unlocked else '' }}"
@@ -286,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', () => {
@@ -308,7 +305,7 @@
function showAchievementPopup(title, reward) {
const popup = document.getElementById('achievementPopup');
document.getElementById('popupTitle').textContent = title;
document.getElementById('popupReward').textContent = reward ? `+${reward} ₽` : '';
document.getElementById('popupReward').textContent = '';
popup.style.display = 'block';
SoundManager.achievement();
setTimeout(() => {
-214
View File
@@ -1,214 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<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">
<style>
.activity-feed {
max-width: 700px;
margin: 0 auto;
}
.activity-item {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 0.5rem;
align-items: flex-start;
transition: all 0.3s;
animation: fadeInUp 0.3s ease;
}
.activity-item:hover {
border-color: var(--primary-color);
}
.activity-icon {
font-size: 1.2rem;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-dark);
border-radius: 8px;
flex-shrink: 0;
}
.activity-content {
flex: 1;
min-width: 0;
}
.activity-message {
font-size: 0.9rem;
line-height: 1.4;
}
.activity-meta {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.activity-username {
color: var(--primary-color);
font-weight: 500;
}
.activity-empty {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.activity-empty .big-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.activity-type-icon {
display: inline-block;
margin-right: 0.3rem;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.activity-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.live-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--success-color);
}
.live-dot {
width: 8px;
height: 8px;
background: var(--success-color);
border-radius: 50%;
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
</head>
<body>
{% include "_nav.html" %}
<main class="container" style="padding: 2rem 1rem;">
<div class="activity-header">
<div>
<h1>📰 Лента активностей</h1>
<p class="page-subtitle">Что происходит на сервере</p>
</div>
<div class="live-indicator" id="liveIndicator">
<span class="live-dot"></span>
<span>Live</span>
</div>
</div>
<div class="activity-feed" id="activityFeed">
{% if activities %}
{% for a in activities %}
<div class="activity-item" data-activity-id="{{ a.id }}">
<div class="activity-icon">
{% if a.activity_type == 'case_open' %}📦
{% elif a.activity_type == 'contract' %}🔄
{% elif a.activity_type == 'upgrade' %}🆙
{% elif a.activity_type == 'achievement' %}🏆
{% elif a.activity_type == 'crash' %}💥
{% else %}📌{% endif %}
</div>
<div class="activity-content">
<div class="activity-message">{{ a.message }}</div>
<div class="activity-meta">
<a href="/profiles/{{ a.user_id }}" class="activity-username" style="text-decoration:none;">{{ a.username }}</a>
<span>• {{ a.created_at }}</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="activity-empty">
<div class="big-icon">📭</div>
<h3>Пока нет активностей</h3>
<p>Открой кейс или выполни контракт — это появится здесь в реальном времени!</p>
</div>
{% endif %}
</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/safemode.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
WS.on('activity', (data) => {
const act = data.activity;
if (!act) return;
const feed = document.getElementById('activityFeed');
const empty = feed.querySelector('.activity-empty');
if (empty) empty.remove();
const iconMap = {
'case_open': '📦',
'contract': '🔄',
'upgrade': '🆙',
'achievement': '🏆',
'crash': '💥'
};
const item = document.createElement('div');
item.className = 'activity-item';
item.style.animation = 'none';
item.offsetHeight;
item.style.animation = 'fadeInUp 0.3s ease';
item.innerHTML = `
<div class="activity-icon">${iconMap[act.activity_type] || '📌'}</div>
<div class="activity-content">
<div class="activity-message">${act.message}</div>
<div class="activity-meta">
<a href="/profiles/${act.user_id || 0}" class="activity-username" style="text-decoration:none;">${escapeHtml(act.username)}</a>
<span>• только что</span>
</div>
</div>
`;
feed.insertBefore(item, feed.firstChild);
while (feed.children.length > 100) {
feed.removeChild(feed.lastChild);
}
if (act.activity_type === 'achievement') {
SoundManager.achievement();
} else if (act.activity_type === 'crash') {
SoundManager.crashCashout();
}
});
});
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
</script>
<script>
function toggleSound() {
const enabled = SoundManager.toggle();
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('soundToggle');
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
});
</script>
</body>
</html>
+154 -12
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; }
@@ -26,6 +26,8 @@
flex-direction: column;
position: sticky; top: 0; height: 100vh;
flex-shrink: 0;
z-index: 50;
transition: transform 0.25s ease;
}
.admin-sidebar .sb-brand {
padding: 1.25rem 1.25rem 0.75rem;
@@ -73,6 +75,28 @@
}
.admin-sidebar .sb-footer a:hover { color: #f59e0b; }
/* ─── Sidebar overlay for mobile ─── */
.sidebar-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
z-index: 49;
}
.sidebar-overlay.open { display: block; }
/* ─── Hamburger ─── */
.hamburger {
display: none;
background: none;
border: none;
color: rgba(255,255,255,0.6);
font-size: 1.3rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.hamburger:hover { color: #fff; }
/* ─── Main area ─── */
.admin-main {
flex: 1;
@@ -85,8 +109,10 @@
padding: 0.85rem 1.5rem;
background: rgba(17,18,22,0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255,255,255,0.04);
position: sticky; top: 0; z-index: 10;
gap: 0.75rem;
}
.admin-topbar .at-title {
font-size: 1rem; font-weight: 600;
@@ -96,7 +122,10 @@
font-size: 0.75rem; color: rgba(255,255,255,0.35);
font-weight: 400;
}
.admin-topbar .at-actions { display: flex; align-items: center; gap: 0.75rem; }
.admin-topbar .at-actions {
display: flex; align-items: center; gap: 0.75rem;
margin-left: auto;
}
.admin-topbar .at-actions .at-back {
color: rgba(255,255,255,0.5); text-decoration: none;
font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem;
@@ -152,6 +181,7 @@
.a-stat .as-value {
font-size: 1.8rem; font-weight: 700;
line-height: 1.1;
word-break: break-word;
}
.a-stat .as-bar {
position: absolute; bottom: 0; left: 0; height: 2px;
@@ -196,6 +226,11 @@
flex-wrap: wrap;
}
/* ─── Responsive tables: card view on mobile ─── */
.a-table-card-view {
display: none;
}
/* ─── Buttons ─── */
.a-btn {
display: inline-flex; align-items: center; gap: 0.35rem;
@@ -255,6 +290,7 @@
color: rgba(255,255,255,0.5);
margin-bottom: 0.35rem;
}
select.a-input { cursor: pointer; }
/* ─── Badge ─── */
.a-badge {
@@ -272,6 +308,7 @@
.a-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 100; display: none; align-items: center; justify-content: center;
padding: 1rem;
}
@@ -283,13 +320,16 @@
padding: 1.5rem;
max-width: 460px; width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
max-height: 90vh; overflow-y: auto;
}
.a-modal-wide { max-width: 700px; }
.a-modal-title {
font-size: 1rem; font-weight: 600; margin-bottom: 1rem;
}
.a-modal-actions {
display: flex; gap: 0.5rem; margin-top: 1rem;
justify-content: flex-end;
flex-wrap: wrap;
}
/* ─── Grid helpers ─── */
@@ -298,7 +338,14 @@
.a-gap-sm { gap: 0.5rem; }
.a-mt { margin-top: 1rem; }
.a-mb { margin-bottom: 1rem; }
.a-flex { display: flex; align-items: center; gap: 0.5rem; }
.a-flex { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
/* ─── Search in top bar ─── */
.search-form {
display: flex; gap: 0.5rem; align-items: center;
}
.search-form .a-input { width: 200px; }
.search-form .a-btn { flex-shrink: 0; }
/* ─── Empty state ─── */
.a-empty {
@@ -307,20 +354,86 @@
}
.a-empty .a-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
@media(max-width:768px){
.admin-sidebar { width: 56px; }
.admin-sidebar .sb-brand span,
.admin-sidebar .sb-nav a span:not(.sb-icon),
.admin-sidebar .sb-footer span { display: none; }
.admin-sidebar .sb-nav a { justify-content: center; padding: 0.65rem; }
.admin-sidebar .sb-footer a { justify-content: center; }
/* ─── Responsive ─── */
@media(max-width: 1024px){
.admin-sidebar { width: 200px; }
}
@media(max-width: 768px){
.admin-sidebar {
position: fixed; left: 0; top: 0;
transform: translateX(-100%);
width: 260px;
box-shadow: 4px 0 30px rgba(0,0,0,0.4);
}
.admin-sidebar.open { transform: translateX(0); }
.admin-content { padding: 1rem; }
.admin-topbar { padding: 0.7rem 1rem; }
.admin-topbar .at-title .at-sub { display: none; }
.hamburger { display: inline-flex; }
.a-stats {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.a-stat { padding: 1rem; }
.a-stat .as-value { font-size: 1.4rem; }
.search-form .a-input { width: 140px; }
.a-modal { margin: 0.5rem; padding: 1rem; border-radius: 12px; }
/* Card-style table on mobile */
.a-table { display: none; }
.a-table-card-view { display: flex; flex-direction: column; gap: 0.75rem; }
.a-table-card {
background: rgba(17,18,22,0.6);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
padding: 0.85rem;
font-size: 0.82rem;
}
.a-table-card .tc-row {
display: flex; justify-content: space-between;
padding: 0.25rem 0;
gap: 0.5rem;
}
.a-table-card .tc-row .tc-label {
color: rgba(255,255,255,0.35);
font-size: 0.72rem;
flex-shrink: 0;
}
.a-table-card .tc-row .tc-value {
text-align: right;
word-break: break-word;
}
.a-table-card .tc-actions {
margin-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid rgba(255,255,255,0.04);
display: flex; gap: 0.35rem; flex-wrap: wrap;
}
}
@media(max-width: 480px){
html { font-size: 14px; }
.admin-content { padding: 0.75rem; }
.admin-topbar { padding: 0.6rem 0.75rem; }
.a-stats { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.a-stat { padding: 0.75rem; }
.a-stat .as-value { font-size: 1.2rem; }
.a-card { padding: 0.85rem; }
.search-form { width: 100%; }
.search-form .a-input { width: 100%; }
}
{% block extra_styles %}{% endblock %}
</style>
</head>
<body>
<div class="admin-sidebar">
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<div class="admin-sidebar" id="adminSidebar">
<div class="sb-brand">
<span style="font-size:1.2rem"></span>
<span>CS2 <span>Admin</span></span>
@@ -344,6 +457,7 @@
<div class="admin-main">
<div class="admin-topbar">
<div class="at-title">
<button class="hamburger" id="hamburgerBtn" aria-label="Toggle menu"></button>
{% block page_title %}Панель управления{% endblock %}
</div>
<div class="at-actions">
@@ -356,7 +470,35 @@
</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');
const overlay = document.getElementById('sidebarOverlay');
const hamburger = document.getElementById('hamburgerBtn');
function toggleSidebar() {
sidebar.classList.toggle('open');
overlay.classList.toggle('open');
}
if (hamburger) {
hamburger.addEventListener('click', toggleSidebar);
}
if (overlay) {
overlay.addEventListener('click', toggleSidebar);
}
// Auto-close sidebar on nav click (mobile)
sidebar.querySelectorAll('.sb-nav a').forEach(function(link) {
link.addEventListener('click', function() {
if (window.innerWidth <= 768) {
toggleSidebar();
}
});
});
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+241 -74
View File
@@ -53,6 +53,32 @@
{% endfor %}
</tbody>
</table>
<div class="a-table-card-view">
{% for c in cases %}
<div class="a-table-card">
<div class="tc-row">
<span class="tc-label">Название</span>
<span class="tc-value"><strong>{{ c.name }}</strong></span>
</div>
<div class="tc-row">
<span class="tc-label">Цена</span>
<span class="tc-value">{{ c.price_open }} ₽</span>
</div>
<div class="tc-row">
<span class="tc-label">Тип</span>
<span class="tc-value">{{ c.display_name|default('') }}</span>
</div>
<div class="tc-row">
<span class="tc-label">Предметов</span>
<span class="tc-value">{{ c.items_count }}</span>
</div>
<div class="tc-actions">
<button class="a-btn a-btn-sm" onclick="openEditCaseModal('{{ c.name }}')">✏️ Редактировать</button>
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteCase('{{ c.name }}')">🗑️</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
@@ -60,9 +86,10 @@
{% block modals %}
<!-- Create/Edit Case Modal -->
<div class="a-overlay" id="caseModal">
<div class="a-modal" style="max-width:600px">
<div class="a-modal a-modal-wide" style="max-height:95vh;display:flex;flex-direction:column">
<div class="a-modal-title" id="caseModalTitle">🎲 Создать кейс</div>
<input type="hidden" id="editCaseName">
<div style="padding:0 1.5rem;flex:1;overflow-y:auto">
<div class="a-row a-gap-sm">
<div class="a-mb" style="flex:1">
<label class="a-label">Название кейса (ID)</label>
@@ -87,30 +114,62 @@
<label class="a-label">Описание (необязательно)</label>
<input type="text" id="caseDescription" class="a-input" placeholder="Описание кейса...">
</div>
<div class="a-card" style="background:rgba(0,0,0,0.2);padding:0.75rem">
<div class="a-card-header" style="margin-bottom:0.5rem">📋 Предметы кейса</div>
<div id="caseItemsList"></div>
<div class="a-flex a-mt" style="flex-wrap:wrap">
<input type="text" id="caseItemSearch" class="a-input a-input-sm" style="flex:1;min-width:120px" placeholder="Поиск предмета..." oninput="searchCaseItems()">
<button class="a-btn a-btn-sm" onclick="openAllItemsModal()">📦 Все предметы</button>
<!-- Item browser section -->
<div class="a-card" style="background:rgba(0,0,0,0.2);padding:0.75rem;margin-bottom:1rem">
<div class="a-card-header" style="margin-bottom:0.5rem">📋 Предметы кейса <span id="itemCount" style="font-size:0.75rem;color:rgba(255,255,255,0.4)">(0)</span></div>
<!-- Selected items list -->
<div id="caseItemsList" style="max-height:200px;overflow-y:auto;margin-bottom:0.75rem"></div>
<!-- Search + filters + add controls -->
<div class="a-row a-gap-sm" style="margin-bottom:0.5rem">
<input type="text" id="caseItemSearch" class="a-input a-input-sm" style="flex:2;min-width:120px" placeholder="🔍 Поиск..." oninput="searchCaseItems()">
<select id="rarityFilter" class="a-input a-input-sm" style="flex:1;min-width:90px" onchange="searchCaseItems()">
<option value="">Редкость</option>
<option value="Consumer Grade">Consumer</option>
<option value="Industrial Grade">Industrial</option>
<option value="Mil-Spec">Mil-Spec</option>
<option value="Restricted">Restricted</option>
<option value="Classified">Classified</option>
<option value="Covert">Covert</option>
<option value="Rare Special Item">Rare Special</option>
<option value="Contraband">Contraband</option>
</select>
<select id="collectionFilter" class="a-input a-input-sm" style="flex:1.2;min-width:100px" onchange="searchCaseItems()">
<option value="">Коллекция</option>
</select>
<select id="sortBy" class="a-input a-input-sm" style="flex:0.7;min-width:80px" onchange="searchCaseItems()">
<option value="">Сортировка</option>
<option value="price_rub">Цена ↑</option>
<option value="price_rub&desc">Цена ↓</option>
<option value="name">Имя ↑</option>
<option value="rarity">Редкость ↑</option>
<option value="rarity&desc">Редкость ↓</option>
</select>
</div>
<div class="a-row a-gap-sm" style="margin-bottom:0.5rem">
<select id="typeFilter" class="a-input a-input-sm" style="flex:1;min-width:70px" onchange="searchCaseItems()">
<option value="">Тип</option>
</select>
<select id="weaponFilter" class="a-input a-input-sm" style="flex:1;min-width:80px" onchange="searchCaseItems()">
<option value="">Оружие</option>
</select>
<input type="number" id="minPrice" class="a-input a-input-sm" style="flex:0.6;min-width:60px" placeholder="Цена от" oninput="searchCaseItems()" value="0">
<input type="number" id="maxPrice" class="a-input a-input-sm" style="flex:0.6;min-width:60px" placeholder="Цена до" oninput="searchCaseItems()" value="0">
</div>
<!-- Search results -->
<div id="caseItemResults" style="max-height:220px;overflow-y:auto;border:1px solid rgba(255,255,255,0.06);border-radius:6px;padding:0.25rem"></div>
</div>
</div>
<div class="a-modal-actions">
<div class="a-modal-actions" style="padding:0.75rem 1.5rem;border-top:1px solid rgba(255,255,255,0.06)">
<button class="a-btn" onclick="closeModal('caseModal')">Отмена</button>
<button class="a-btn a-btn-primary" id="saveCaseBtn" onclick="saveCase()">💾 Сохранить</button>
</div>
</div>
</div>
<!-- All items browser modal -->
<div class="a-overlay" id="allItemsModal">
<div class="a-modal" style="max-width:700px;max-height:80vh;display:flex;flex-direction:column">
<div class="a-modal-title">📦 Все предметы</div>
<input type="text" id="allItemsSearch" class="a-input a-input-sm a-mb" placeholder="Поиск..." oninput="searchAllItems()">
<div id="allItemsGrid" style="flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.5rem"></div>
</div>
</div>
<!-- Add case to section modal -->
<div class="a-overlay" id="addCaseModal">
<div class="a-modal" style="max-width:400px">
@@ -145,13 +204,26 @@
{% block extra_styles %}
<style>
.ci-item { display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0; border-bottom:1px solid rgba(255,255,255,0.03); font-size:0.78rem; }
.ci-item img { width:32px; height:24px; object-fit:contain; }
.ci-item .ci-info { flex:1; }
.ci-item {
display:flex; align-items:center; gap:0.5rem;
padding:0.3rem 0.5rem; border-bottom:1px solid rgba(255,255,255,0.04);
font-size:0.78rem; transition:background 0.15s;
}
.ci-item:hover { background:rgba(255,255,255,0.03); }
.ci-item img { width:36px; height:28px; object-fit:contain; border-radius:3px; }
.ci-item .ci-info { flex:1; line-height:1.3; }
.ci-item .ci-rarity { font-size:0.65rem; color:rgba(255,255,255,0.35); }
.all-item-card { background:rgba(0,0,0,0.15); border-radius:6px; padding:0.35rem; cursor:pointer; text-align:center; font-size:0.7rem; transition:all 0.15s; border:1px solid transparent; }
.all-item-card:hover { border-color:rgba(245,158,11,0.3); background:rgba(245,158,11,0.05); }
.all-item-card img { width:100%; height:40px; object-fit:contain; }
.ci-result {
display:flex; align-items:center; gap:0.5rem;
padding:0.3rem 0.5rem; cursor:pointer;
border-bottom:1px solid rgba(255,255,255,0.03);
font-size:0.78rem; transition:all 0.12s; border-radius:3px;
}
.ci-result:hover { background:rgba(245,158,11,0.08); }
.ci-result img { width:36px; height:28px; object-fit:contain; border-radius:3px; }
.ci-result .ci-info { flex:1; line-height:1.3; }
.ci-result .ci-meta { font-size:0.65rem; color:rgba(255,255,255,0.35); }
.ci-result .ci-add { font-size:0.7rem; color:#f59e0b; }
</style>
{% endblock %}
@@ -159,17 +231,42 @@
<script>
let currentCaseItems = [];
let editingCaseName = null;
let searchDebounce = null;
// ─── Modals ──────────────────────────────────────────────────────────────────
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
function openModal(id) { document.getElementById(id).classList.add('open'); }
// ─── Load page data ──────────────────────────────────────────────────────────
let allCasesList = [];
// ─── Load filter options ─────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
const r = await fetch('/admin/api/cases/list');
const d = await r.json();
allCasesList = d.cases || [];
allCasesList = await r.json();
// Load collections
const cr = await fetch('/admin/api/collections');
const cd = await cr.json();
const colSel = document.getElementById('collectionFilter');
(cd.collections || []).forEach(c => {
const o = document.createElement('option');
o.value = c; o.textContent = c;
colSel.appendChild(o);
});
// Load weapons + types
const fr = await fetch('/admin/api/items/filters');
const fd = await fr.json();
const typeSel = document.getElementById('typeFilter');
(fd.types || []).forEach(t => {
const o = document.createElement('option');
o.value = t; o.textContent = t;
typeSel.appendChild(o);
});
const weaponSel = document.getElementById('weaponFilter');
(fd.weapons || []).forEach(w => {
const o = document.createElement('option');
o.value = w; o.textContent = w;
weaponSel.appendChild(o);
});
});
// ─── Create / Edit Case ──────────────────────────────────────────────────────
@@ -185,6 +282,8 @@ function openCreateCaseModal() {
document.getElementById('caseDescription').value = '';
currentCaseItems = [];
renderCaseItems();
document.getElementById('caseItemSearch').value = '';
document.getElementById('caseItemResults').innerHTML = '';
openModal('caseModal');
}
@@ -200,21 +299,34 @@ function openEditCaseModal(caseName) {
document.getElementById('casePrice').value = c.price_open || 250;
document.getElementById('caseImage').value = c.image_url || '';
document.getElementById('caseDescription').value = c.description || '';
currentCaseItems = (c.items || []).filter(i => typeof i === 'object');
currentCaseItems = (c.items || []).filter(i => typeof i === 'object').map(i => {
if (typeof i.id === 'number' && !i.market_hash_name) {
return { id: i.id, name: i.name || 'Item #' + i.id, market_hash_name: i.name || '' };
}
return i;
});
renderCaseItems();
document.getElementById('caseItemSearch').value = '';
document.getElementById('caseItemResults').innerHTML = '';
openModal('caseModal');
}
function renderCaseItems() {
const div = document.getElementById('caseItemsList');
const count = currentCaseItems.length;
document.getElementById('itemCount').textContent = `(${count})`;
if (count === 0) {
div.innerHTML = '<div style="font-size:0.75rem;color:rgba(255,255,255,0.3);padding:0.5rem 0">Нет предметов — добавьте через поиск выше</div>';
return;
}
div.innerHTML = currentCaseItems.map((item, i) => `
<div class="ci-item">
<div class="ci-item" onclick="removeCaseItem(${i})" style="cursor:pointer">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ci-info">
<div>${item.market_hash_name || item.name || '???'}</div>
<div class="ci-rarity">${item.rarity || ''} • ${item.weapon || ''}</div>
<div class="ci-rarity">${item.rarity || ''}${item.collection ? ' • ' + item.collection : ''}</div>
</div>
<button class="a-btn a-btn-danger a-btn-sm" onclick="removeCaseItem(${i})"></button>
<span style="font-size:0.65rem;color:#ef4444;opacity:0.6"></span>
</div>
`).join('');
}
@@ -225,31 +337,105 @@ function removeCaseItem(idx) {
}
function addCaseItem(item) {
if (currentCaseItems.some(i => (i.market_hash_name || i.name) === (item.market_hash_name || item.name))) return;
const key = (item.market_hash_name || item.name || '');
if (currentCaseItems.some(i => (i.market_hash_name || i.name) === key)) return;
currentCaseItems.push(item);
renderCaseItems();
}
function addAllFromCollection() {
const sel = document.getElementById('collectionFilter');
const coll = sel.value;
if (!coll) { Notify.error('Выберите коллекцию'); return; }
const rarity = document.getElementById('rarityFilter').value;
const typeF = document.getElementById('typeFilter').value;
const weaponF = document.getElementById('weaponFilter').value;
let url = `/admin/api/items/by-collection?collection=${encodeURIComponent(coll)}`;
if (rarity) url += `&rarity_filter=${encodeURIComponent(rarity)}`;
if (typeF) url += `&type_filter=${encodeURIComponent(typeF)}`;
if (weaponF) url += `&weapon_filter=${encodeURIComponent(weaponF)}`;
fetch(url)
.then(r => r.json())
.then(items => {
let added = 0;
let skipped = 0;
const alreadyIds = new Set(currentCaseItems.map(i => i.id));
items.forEach(item => {
if (alreadyIds.has(item.id)) {
skipped++;
return;
}
const key = (item.name || '');
if (!currentCaseItems.some(i => (i.market_hash_name || i.name) === key)) {
currentCaseItems.push(item);
added++;
}
});
renderCaseItems();
let msg = `Добавлено ${added} предметов из "${coll}"`;
if (skipped) msg += ` (${skipped} уже были в кейсе)`;
Notify.success(msg);
});
}
async function searchCaseItems() {
const q = document.getElementById('caseItemSearch').value;
if (q.length < 2) return;
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}`);
const items = await r.json();
// show as dropdown
let html = '<div style="margin-top:0.5rem;max-height:200px;overflow-y:auto">';
items.forEach(item => {
html += `<div class="ci-item" style="cursor:pointer" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ci-info"><div>${item.name}</div><div class="ci-rarity">${item.rarity} • ${item.price_rub||0} ₽</div></div>
</div>`;
});
html += '</div>';
const existing = document.getElementById('caseItemSearchResults');
if (!existing) {
const d = document.createElement('div'); d.id = 'caseItemSearchResults';
document.getElementById('caseItemSearch').parentNode.appendChild(d);
const rarity = document.getElementById('rarityFilter').value;
const collection = document.getElementById('collectionFilter').value;
const typeF = document.getElementById('typeFilter').value;
const weaponF = document.getElementById('weaponFilter').value;
const minP = document.getElementById('minPrice').value;
const maxP = document.getElementById('maxPrice').value;
const sortV = document.getElementById('sortBy').value;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(async () => {
const params = new URLSearchParams();
if (q.length >= 2) params.set('q', q);
if (rarity) params.set('rarity_filter', rarity);
if (collection) params.set('collection_filter', collection);
if (typeF) params.set('type_filter', typeF);
if (weaponF) params.set('weapon_filter', weaponF);
if (minP > 0) params.set('min_price', minP);
if (maxP > 0) params.set('max_price', maxP);
if (sortV) {
const [field, order] = sortV.split('&');
params.set('sort_by', field);
if (order) params.set('sort_order', 'desc');
}
document.getElementById('caseItemSearchResults').innerHTML = html;
params.set('limit', '100');
const r = await fetch(`/admin/api/items/search?${params.toString()}`);
const items = await r.json();
const div = document.getElementById('caseItemResults');
if (items.length === 0) {
div.innerHTML = '<div style="font-size:0.75rem;color:rgba(255,255,255,0.3);padding:0.5rem">Ничего не найдено</div>';
return;
}
let html = '';
if (collection) {
html += `<div style="padding:0.3rem 0.5rem;font-size:0.7rem;color:rgba(255,255,255,0.4);display:flex;justify-content:space-between">
<span>Найдено: ${items.length} предметов</span>
<span onclick="addAllFromCollection()" style="cursor:pointer;color:#f59e0b">➕ Добавить все из коллекции</span>
</div>`;
}
const alreadyIds = new Set(currentCaseItems.map(i => i.id));
html += items.filter(item => !alreadyIds.has(item.id)).map(item => {
const priceStr = item.price_rub > 0 ? item.price_rub.toLocaleString('ru') + '₽' : '';
return `
<div class="ci-result" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ci-info">
<div>${item.name}</div>
<div class="ci-meta">${item.rarity}${item.weapon ? ' • ' + item.weapon : ''}${priceStr ? ' • ' + priceStr : ''}</div>
</div>
<div class="ci-add">+</div>
</div>`;
}).join('');
const hidden = items.filter(item => alreadyIds.has(item.id)).length;
if (hidden > 0) {
html += `<div style="font-size:0.65rem;color:rgba(255,255,255,0.25);padding:0.3rem 0.5rem">${hidden} предмет(ов) уже в кейсе</div>`;
}
div.innerHTML = html;
}, q.length >= 2 ? 200 : 300);
}
async function saveCase() {
@@ -263,11 +449,15 @@ async function saveCase() {
fd.append('description', document.getElementById('caseDescription').value);
fd.append('items_json', JSON.stringify(currentCaseItems));
const isEdit = editingCaseName !== null && editingCaseName !== name;
const isEdit = editingCaseName !== null;
const url = isEdit
? `/admin/api/cases/update/${encodeURIComponent(editingCaseName)}`
: '/admin/api/cases/create';
if (isEdit && editingCaseName !== name) {
fd.append('new_name', name);
}
const r = await fetch(url, { method:'POST', body:fd });
const d = await r.json();
if (d.success) { window.location.reload(); }
@@ -285,29 +475,6 @@ async function deleteCase(name) {
});
}
// ─── All Items Browser ───────────────────────────────────────────────────────
async function openAllItemsModal() {
openModal('allItemsModal');
document.getElementById('allItemsSearch').value = '';
await searchAllItems();
}
async function searchAllItems() {
const q = document.getElementById('allItemsSearch').value;
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}&limit=200`);
const items = await r.json();
document.getElementById('allItemsGrid').innerHTML = items.map(item => `
<div class="all-item-card" onclick="addCaseItemFromAll(${JSON.stringify(item).replace(/"/g,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div style="margin-top:0.2rem;line-height:1.15">${item.name}</div>
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35)">${item.rarity}</div>
</div>
`).join('');
}
function addCaseItemFromAll(item) {
addCaseItem(item);
closeModal('allItemsModal');
}
// ─── Mainpage Sections ───────────────────────────────────────────────────────
function openAddSectionModal() {
document.getElementById('newSectionName').value = '';
@@ -336,7 +503,7 @@ async function deleteSection(tab) {
function openAddCaseModal(tab) {
document.getElementById('addCaseSectionName').textContent = tab;
const sel = document.getElementById('addCaseSelect');
sel.innerHTML = allCasesList.map(c => `<option value="${c}">${c}</option>`).join('');
sel.innerHTML = allCasesList.map(c => `<option value="${c.name}">${c.display_name || c.name}</option>`).join('');
sel.dataset.tab = tab;
openModal('addCaseModal');
}
+27
View File
@@ -4,6 +4,13 @@
{% block page_title %}📊 Дашборд{% endblock %}
{% block content %}
<div class="a-card" style="margin-bottom:1rem;padding:0.75rem">
<div style="display:flex;justify-content:space-between;align-items:center">
<strong>🎭 Промо-фаза:</strong>
<span id="promoPhaseInfo">Загрузка...</span>
</div>
</div>
<div class="a-stats">
<div class="a-stat">
<div class="as-label">Пользователей</div>
@@ -55,3 +62,23 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function loadPromoPhase() {
try {
const r = await fetch('/admin/api/promo-phase');
const d = await r.json();
const el = document.getElementById('promoPhaseInfo');
if (d.current_phase === 'LUCKY') {
el.innerHTML = '<span style="color:#22c55e">🍀 LUCKY</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
} else if (d.current_phase === 'UNLUCKY') {
el.innerHTML = '<span style="color:#ef4444">💀 UNLUCKY</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
} else {
el.innerHTML = '<span style="color:#a3a3a3">⚪ NEUTRAL</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
}
} catch(e) {}
}
document.addEventListener('DOMContentLoaded', loadPromoPhase);
</script>
{% endblock %}
+61 -1
View File
@@ -8,10 +8,11 @@
{% endblock %}
{% block top_actions %}
<a href="/admin/users" class="at-back">&larr; Назад</a>
<a href="/admin/user/{{ target_user.id }}/history" class="a-btn" style="margin-left:0.5rem;font-size:0.82rem;">📜 История</a>
{% endblock %}
{% block content %}
<div class="a-stats" style="grid-template-columns:repeat(4,1fr)">
<div class="a-stats">
<div class="a-stat">
<div class="as-label">Баланс</div>
<div class="as-value">{{ target_user.balance|money }} ₽</div>
@@ -49,6 +50,26 @@
</div>
</div>
<!-- RPU v2 -->
<div class="a-card">
<div class="a-card-header">🎲 RPU v2 — подробно</div>
<div id="rpuV2Stats" class="a-mb" style="font-size:0.82rem">
<div class="a-row a-gap-sm" style="justify-content:space-around;text-align:center;margin-bottom:0.75rem">
<div><div class="as-value" id="v2Ceiling" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Потолок / Депнуто</div></div>
<div><div class="as-value" id="v2Budget" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Luck Budget</div></div>
<div><div class="as-value" id="v2SessionSpent" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Сессия потрачено</div></div>
<div><div class="as-value" id="v2HotScore" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Hot Score</div></div>
<div><div class="as-value" id="v2PromoPhase" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Промо-фаза</div></div>
</div>
<div class="a-row a-gap-sm" style="justify-content:space-around;text-align:center">
<div><div class="as-value" id="v2Comeback" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Комбек</div></div>
<div><div class="as-value" id="v2BreakCount" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Пробитий</div></div>
<div><div class="as-value" id="v2LossStreak" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Проигрышей подряд</div></div>
<div><div class="as-value" id="v2SessionReset" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Сброс сессии</div></div>
</div>
</div>
</div>
<!-- Действия -->
<div class="a-card">
<div class="a-card-header">🔧 Действия</div>
@@ -84,6 +105,27 @@
{% endfor %}
</tbody>
</table>
<div class="a-table-card-view">
{% for item in inventory %}
<div class="a-table-card">
<div class="tc-row">
<span class="tc-label">ID</span>
<span class="tc-value">{{ item.id }}</span>
</div>
<div class="tc-row">
<span class="tc-label">Название</span>
<span class="tc-value">{{ item.market_hash_name[:40] }}{{'…' if item.market_hash_name|length > 40}}</span>
</div>
<div class="tc-row">
<span class="tc-label">Редкость / Float</span>
<span class="tc-value">{{ item.rarity }} / {{ "%.4f"|format(item.float_value) }}</span>
</div>
<div class="tc-actions">
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteItem({{ item.id }})">🗑️ Удалить</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
@@ -251,6 +293,24 @@ async function loadRPU() {
document.getElementById('rpuSpent').textContent = Math.round(d.stats.total_spent).toLocaleString() + ' ₽';
document.getElementById('rpuOpened').textContent = d.stats.total_opened.toLocaleString();
document.getElementById('rpuAutoStatus').textContent = d.auto_adjust ? '✅' : '❌';
// ── RPU v2 ──
if (d.v2) {
const v = d.v2;
document.getElementById('v2Ceiling').textContent = Math.round(v.ceiling.current_ceiling).toLocaleString('ru') + '₽ / ' + Math.round(v.ceiling.total_deposited).toLocaleString('ru') + '₽';
document.getElementById('v2Budget').textContent = v.session.luck_budget + '%';
document.getElementById('v2Budget').style.color = v.session.luck_budget < 30 ? '#ef4444' : v.session.luck_budget > 70 ? '#22c55e' : '#f59e0b';
document.getElementById('v2SessionSpent').textContent = Math.round(v.session.session_spent).toLocaleString('ru') + '₽';
document.getElementById('v2HotScore').textContent = v.hot_cold.hot_score;
document.getElementById('v2HotScore').style.color = v.hot_cold.hot_score > 0.5 ? '#22c55e' : v.hot_cold.hot_score < -0.5 ? '#ef4444' : '#f59e0b';
const ph = v.promo_phase;
document.getElementById('v2PromoPhase').textContent = ph.phase;
document.getElementById('v2PromoPhase').style.color = ph.phase === 'LUCKY' ? '#22c55e' : ph.phase === 'UNLUCKY' ? '#ef4444' : '#f59e0b';
document.getElementById('v2Comeback').textContent = v.comeback.active ? '✅ x' + v.comeback.multiplier.toFixed(1) + ' (' + v.comeback.openings_left + ')' : '❌';
document.getElementById('v2BreakCount').textContent = v.ceiling.break_count + ' (' + v.ceiling.break_session + ' сесс)';
document.getElementById('v2LossStreak').textContent = v.comeback.consecutive_loss_count + ' (' + Math.round(v.comeback.consecutive_loss_value).toLocaleString('ru') + '₽)';
document.getElementById('v2SessionReset').textContent = v.session.reset_date ? new Date(v.session.reset_date).toLocaleString('ru') : '—';
}
} catch(_) {}
}
+122
View File
@@ -0,0 +1,122 @@
{% extends "admin/base.html" %}
{% block title %}История {{ target_user.username }} — Админ-панель{% endblock %}
{% block nav_users %}active{% endblock %}
{% block page_title %}
📜 История {{ target_user.username }}
<span class="at-sub">ID {{ target_user.id }}</span>
{% endblock %}
{% block top_actions %}
<a href="/admin/user/{{ target_user.id }}" class="at-back">&larr; Профиль</a>
{% endblock %}
{% block extra_styles %}
<style>
.rarity-Covert, .rarity-Rare\ Special\ Item, .rarity-Extraordinary { color: #eb4b4b; }
.rarity-Classified { color: #d32ce6; }
.rarity-Restricted { color: #8847ff; }
.rarity-Mil-Spec, .rarity-Mil-Spec\ Grade { color: #4b69ff; }
.rarity-Industrial\ Grade { color: #5e98d9; }
.rarity-Consumer\ Grade { color: #b0b0b0; }
.mono { font-family: monospace; font-size: 0.72rem; }
</style>
{% endblock %}
{% block content %}
<div class="a-stats" style="margin-bottom:1rem">
<div class="a-stat"><div class="as-label">Потолок</div><div class="as-value">{{ "%.0f"|format(ceiling) }} ₽</div></div>
<div class="a-stat"><div class="as-label">Текущая ценность</div><div class="as-value">{{ "%.0f"|format(total_value) }} ₽</div></div>
<div class="a-stat"><div class="as-label">Luck budget</div><div class="as-value">{{ "%.1f"|format(rpu.luck_budget) }}%</div></div>
<div class="a-stat"><div class="as-label">Hot score</div><div class="as-value">{{ "%.0f"|format(rpu.hot_score or 0) }}</div></div>
<div class="a-stat"><div class="as-label">Ceiling mult</div><div class="as-value">{{ "%.2f"|format(rpu.ceiling_multiplier) }}</div></div>
<div class="a-stat"><div class="as-label">Комбек</div><div class="as-value">{{ rpu.comeback_active }}</div></div>
</div>
<div class="a-flex a-mb" style="gap:0.5rem">
<button class="a-btn a-btn-primary tab-btn active" onclick="switchTab('openings')">📦 Открытия ({{ openings|length }})</button>
<button class="a-btn tab-btn" onclick="switchTab('upgrades')">⬆️ Апгрейды ({{ upgrades|length }})</button>
<button class="a-btn tab-btn" onclick="switchTab('transactions')">💳 Транзакции ({{ transactions|length }})</button>
</div>
<div id="tab-openings" class="tab-content" style="display:block">
<div class="a-table-wrap">
<table class="a-table">
<thead><tr><th>ID</th><th>Дата</th><th>Кейс</th><th>Предмет</th><th>Редкость</th><th>Float</th></tr></thead>
<tbody>
{% for o in openings %}
<tr>
<td class="mono">{{ o.id }}</td>
<td>{{ o.opened_at.strftime('%d.%m %H:%M') }}</td>
<td>{{ o.case_name }}</td>
<td>{{ o.item_name }}</td>
<td><span class="rarity-{{ o.rarity|replace(' ', '\\ ') }}">{{ o.rarity }}</span></td>
<td>{{ "%.4f"|format(o.float_value) if o.float_value else '—' }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="a-empty" style="padding:2rem">Нет открытий</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div id="tab-upgrades" class="tab-content" style="display:none">
<div class="a-table-wrap">
<table class="a-table">
<thead><tr><th>ID</th><th>Дата</th><th>Вход</th><th>Цель</th><th>Результат</th><th>Базовый шанс</th><th>RPU шанс</th></tr></thead>
<tbody>
{% for u in upgrades %}
<tr>
<td class="mono">{{ u.id }}</td>
<td>{{ u.created_at.strftime('%d.%m %H:%M') }}</td>
<td>{{ u.input_item_name }}</td>
<td>{{ u.target_item_name }}</td>
<td style="color:{% if u.success %}#22c55e{% else %}#ef4444{% endif %}">{% if u.success %}✅ Успех{% else %}❌ Провал{% endif %}</td>
<td>{{ "%.1f"|format(u.probability * 100 if u.probability else 0) }}%</td>
<td>{{ "%.1f"|format(u.rpu_adjusted_probability * 100 if u.rpu_adjusted_probability else 0) }}%</td>
</tr>
{% else %}
<tr><td colspan="7" class="a-empty" style="padding:2rem">Нет апгрейдов</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div id="tab-transactions" class="tab-content" style="display:none">
<div class="a-table-wrap">
<table class="a-table">
<thead><tr><th>ID</th><th>Дата</th><th>Тип</th><th>Сумма</th><th>Комиссия</th><th>Детали</th></tr></thead>
<tbody>
{% for t in transactions %}
<tr>
<td class="mono">{{ t.id }}</td>
<td>{{ t.created_at.strftime('%d.%m %H:%M') }}</td>
<td>
{% if t.tx_type == 'card_to_site' %}<span class="a-badge a-badge-success">Депозит</span>
{% elif t.tx_type == 'site_to_card' %}<span class="a-badge a-badge-banned">Вывод</span>
{% elif t.tx_type == 'promo_bonus' %}<span class="a-badge" style="background:rgba(234,179,8,0.12);color:#eab308">Промо</span>
{% else %}<span class="a-badge a-badge-user">{{ t.tx_type }}</span>{% endif %}
</td>
<td>{{ "%.0f"|format(t.amount) }} ₽</td>
<td>{% if t.fee %}{{ "%.0f"|format(t.fee) }} ₽{% else %}—{% endif %}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ t.details or '' }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="a-empty" style="padding:2rem">Нет транзакций</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function switchTab(name) {
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + name).style.display = 'block';
document.querySelector('.tab-btn[onclick="switchTab(\'' + name + '\')"]').classList.add('active');
}
</script>
{% endblock %}
+36 -2
View File
@@ -3,8 +3,8 @@
{% block nav_users %}active{% endblock %}
{% block page_title %}👥 Пользователи{% endblock %}
{% block top_actions %}
<div class="a-flex">
<input type="text" id="userSearch" class="a-input a-input-sm" style="width:200px" placeholder="Поиск по имени..." value="{{ search or '' }}">
<div class="search-form a-flex">
<input type="text" id="userSearch" class="a-input a-input-sm" placeholder="Поиск по имени..." value="{{ search or '' }}">
<button class="a-btn a-btn-primary" onclick="searchUsers()">🔍 Найти</button>
</div>
{% endblock %}
@@ -50,6 +50,40 @@
{% endfor %}
</tbody>
</table>
<div class="a-table-card-view">
{% for u in users %}
<div class="a-table-card">
<div class="tc-row">
<span class="tc-label">ID</span>
<span class="tc-value">{{ u.id }}</span>
</div>
<div class="tc-row">
<span class="tc-label">Имя</span>
<span class="tc-value"><a href="/admin/user/{{ u.id }}" style="color:#f59e0b;text-decoration:none;font-weight:500">{{ u.username }}</a></span>
</div>
<div class="tc-row">
<span class="tc-label">Баланс</span>
<span class="tc-value">{{ u.balance|money }} ₽</span>
</div>
<div class="tc-row">
<span class="tc-label">Статус</span>
<span class="tc-value">
{% if u.is_admin %}<span class="a-badge a-badge-admin">Админ</span>{% endif %}
{% if u.is_banned %}<span class="a-badge a-badge-banned">Забанен</span>{% endif %}
{% if not u.is_admin and not u.is_banned %}<span class="a-badge a-badge-user">Игрок</span>{% endif %}
</span>
</div>
<div class="tc-row">
<span class="tc-label">Кейсов / Контр / Предм</span>
<span class="tc-value">{{ u.case_openings }} / {{ u.contracts }} / {{ u.inventory_count }}</span>
</div>
<div class="tc-actions">
<a href="/admin/user/{{ u.id }}" class="a-btn a-btn-sm">✏️ Редактировать</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="a-empty">
+34 -15
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 {
@@ -519,9 +519,10 @@
const data = await response.json();
if (data.success) {
userBalance = data.balance;
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.balance)} ₽`;
});
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>';
}
updatePriceDisplay();
}
} catch (e) {
@@ -1091,7 +1092,7 @@
const inventoryIds = [];
for (const trashItem of trashItems) {
const invItem = items.find(i => i.item_id === trashItem.id &&
Math.abs(i.float - trashItem.float) < 0.001);
Math.abs(i.float - trashItem.float) < 0.01);
if (invItem) {
inventoryIds.push(invItem.id);
}
@@ -1101,10 +1102,28 @@
const formData = new FormData();
formData.append('inventory_ids', JSON.stringify(inventoryIds));
await fetch('/web/api/inventory/sell/bulk', {
const res = await fetch('/web/api/inventory/sell/bulk', {
method: 'POST',
body: formData
});
if (!res.ok) {
await new Promise(r => setTimeout(r, 500));
const retry = await fetch('/web/api/inventory/items');
const items2 = await retry.json();
const retryIds = [];
for (const trashItem of trashItems) {
const invItem = items2.find(i => i.item_id === trashItem.id &&
Math.abs(i.float - trashItem.float) < 0.01);
if (invItem && !inventoryIds.includes(invItem.id)) {
retryIds.push(invItem.id);
}
}
if (retryIds.length > 0) {
const fd2 = new FormData();
fd2.append('inventory_ids', JSON.stringify(retryIds));
await fetch('/web/api/inventory/sell/bulk', { method: 'POST', body: fd2 });
}
}
}
} catch (e) {
console.error('Ошибка продажи шлака:', e);
@@ -1702,7 +1721,7 @@
</div>
<div class="result-footer-enhanced">
<span class="result-price-enhanced">${(item.price || 100).toLocaleString()} ₽</span>
<button class="btn-sell-result" onclick="sellResultItem(${item.id})">Продать</button>
<button class="btn-sell-result" onclick="sellResultItem(${item.id}, ${item.float})">Продать</button>
</div>
</div>
${item.count > 1 ? `<span class="result-count-badge">x${item.count}</span>` : ''}
@@ -1724,13 +1743,14 @@
});
}
async function sellResultItem(itemId) {
async function sellResultItem(itemId, itemFloat) {
Notify.confirm('Продать этот предмет?', 'Продажа', async function(confirmed) {
if (!confirmed) return;
try {
const response = await fetch('/web/api/inventory/items');
const items = await response.json();
const inventoryItem = items.find(i => i.item_id === itemId);
const inventoryItem = items.find(i => i.item_id === itemId &&
Math.abs(i.float - (itemFloat || 0)) < 0.001);
if (!inventoryItem) {
Notify.error('Предмет не найден в инвентаре');
@@ -1855,18 +1875,17 @@
}
});
</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) {
data.achievements_unlocked.forEach(ach => {
const title = ach.title || ach.name || 'Достижение';
const reward = ach.reward || '';
showAchievementPopup(title, reward);
showAchievementPopup(title);
});
}
}
+151 -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;
@@ -152,6 +152,85 @@
.btn-open:hover {
background: var(--primary-hover);
}
.promo-wrapper {
background: linear-gradient(135deg, #1a1a2e 0%, #2d1b3d 50%, #1a1a2e 100%);
border: 1px solid rgba(255,215,0,0.2);
border-radius: 12px;
margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
box-shadow: 0 0 20px rgba(255,215,0,0.05);
}
.promo-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.promo-info { display: flex; align-items: center; gap: 0.75rem; }
.promo-info--card { text-align: center; }
.promo-info--label {
font-size: 1.6rem;
font-weight: 700;
color: #ffd700;
line-height: 1;
}
.promo-info--label-bottom {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-dim);
letter-spacing: 0.05em;
}
.promo-timer--inner { display: flex; align-items: center; gap: 0.5rem; }
.promo-timer--label {
font-size: 0.7rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.promo-timer--countdown {
font-size: 1.1rem;
font-weight: 700;
color: #ffd700;
font-variant-numeric: tabular-nums;
}
.promo-code { display: flex; align-items: center; gap: 0.5rem; }
.promo-code--label {
font-size: 0.65rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.promo-code--label-light { color: #ffd700; font-weight: 600; }
.promo-code--button {
background: linear-gradient(135deg, #ffd700, #f59e0b);
border: none;
border-radius: 8px;
padding: 0.4rem 1rem;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
color: #1a1a2e;
transition: transform 0.15s, box-shadow 0.15s;
position: relative;
}
.promo-code--button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255,215,0,0.4);
}
.promo-code--button:active { transform: translateY(0); }
.promo-code--button .inner { position: relative; z-index: 2; }
.promo-code--button.copied {
background: linear-gradient(135deg, #16a34a, #15803d);
color: white;
}
@media (max-width: 640px) {
.promo-container { flex-direction: column; align-items: stretch; text-align: center; }
.promo-info { justify-content: center; }
.promo-timer--inner { justify-content: center; }
.promo-code { justify-content: center; flex-wrap: wrap; }
}
</style>
</head>
<body>
@@ -162,6 +241,33 @@
<h1>📦 Кейсы</h1>
<p class="page-subtitle">Выберите кейс для открытия</p>
{% if auto_promo and user %}
<section class="promo-wrapper" id="promocode-holder">
<div class="promo-container">
<div class="promo-info">
<div class="promo-info--card">
<div class="promo-info--label ff-secondary">+{{ auto_promo.reward_amount }}%</div>
<div class="promo-info--label-bottom">НА СЧЕТ</div>
</div>
</div>
<div class="promo-timer">
<div class="promo-timer--inner">
<p class="promo-timer--label">ОСТАЛОСЬ ВРЕМЕНИ:</p>
<span class="ff-secondary promo-timer--countdown" id="promocode-countdown" data-seconds="{{ auto_promo.expires_in_seconds }}">
<span>00</span>:<span>00</span>:<span>00</span>
</span>
</div>
</div>
<div class="promo-code" id="coupon-holder" data-promocode="{{ auto_promo.code }}">
<p class="promo-code--label">ИСПОЛЬЗУЙ <span class="promo-code--label-light">ПРОМОКОД:</span></p>
<button class="promo-code--button point-btn" onclick="copyAndOpenPromo('{{ auto_promo.code }}', this)">
<span class="inner">{{ auto_promo.code }}</span>
</button>
</div>
</div>
</section>
{% endif %}
<div class="cases-sections">
{% for section in sections %}
<div class="section-block">
@@ -207,15 +313,56 @@
</div>
</main>
<script>
// Промо-баннер таймер
(function() {
var el = document.getElementById('promocode-countdown');
if (!el) return;
var total = parseInt(el.dataset.seconds, 10) || 0;
var spans = el.querySelectorAll('span');
function tick() {
if (total <= 0) { spans[0].textContent = '00'; spans[1].textContent = '00'; spans[2].textContent = '00'; return; }
total--;
var h = Math.floor(total / 3600);
var m = Math.floor((total % 3600) / 60);
var s = total % 60;
spans[0].textContent = String(h).padStart(2, '0');
spans[1].textContent = String(m).padStart(2, '0');
spans[2].textContent = String(s).padStart(2, '0');
}
tick();
setInterval(tick, 1000);
})();
function copyAndOpenPromo(code, btn) {
if (!btn) btn = document.querySelector('.promo-code--button');
navigator.clipboard.writeText(code).then(function() {
btn.classList.add('copied');
btn.innerHTML = '<span class="inner">✓ СКОПИРОВАНО</span>';
setTimeout(function() {
btn.classList.remove('copied');
btn.innerHTML = '<span class="inner">' + code + '</span>';
}, 2000);
}).catch(function() {});
// Открываем депозит-попап и вставляем код
var overlay = document.getElementById('depositOverlay');
if (overlay) {
overlay.classList.add('open');
var input = document.getElementById('promoInput');
if (input) { input.value = code; input.focus(); }
// Грузим балансы
if (typeof loadDepositData === 'function') loadDepositData();
}
}
</script>
<script>
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
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() {
+6 -6
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" %}
@@ -685,7 +685,7 @@
function handleAchievements(d) {
if(d && d.achievements_unlocked && Array.isArray(d.achievements_unlocked) && d.achievements_unlocked.length)
d.achievements_unlocked.forEach(a => showAchievementPopup(a.title || a.name || 'Достижение', a.reward || ''));
d.achievements_unlocked.forEach(a => showAchievementPopup(a.title || a.name || 'Достижение'));
}
function toggleSound() {
const e = SoundManager.toggle();
@@ -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>
+12 -10
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');
@@ -445,9 +445,10 @@
myActiveBet = data.bet;
myCashoutMult = null;
SoundManager.crashBet();
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.new_balance)} ₽`;
});
const el = document.getElementById('siteBalanceDisplay');
if (el) {
el.innerHTML = Math.floor(data.new_balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
}
updateUI();
} else {
SoundManager.error();
@@ -468,9 +469,10 @@
if (data.success) {
myCashoutMult = data.multiplier;
SoundManager.crashCashout();
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.new_balance)} ₽`;
});
const el = document.getElementById('siteBalanceDisplay');
if (el) {
el.innerHTML = Math.floor(data.new_balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
}
updateUI();
} else {
SoundManager.error();
+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" %}
+166 -14
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;
@@ -140,6 +140,32 @@
transform: none;
}
.withdraw-selected-btn {
padding: 0.45rem 1.25rem;
background: var(--success-color);
color: #000;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
font-size: 0.78rem;
transition: all 0.2s;
font-family: 'Russo One', sans-serif;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.withdraw-selected-btn:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.withdraw-selected-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.sell-all-btn {
padding: 0.45rem 1.25rem;
background: transparent;
@@ -234,6 +260,25 @@
color: white;
}
.btn-withdraw-single {
flex: 1;
padding: 0.3rem;
background: transparent;
border: 1px solid var(--success-color);
color: var(--success-color);
border-radius: 6px;
cursor: pointer;
font-size: 0.7rem;
transition: all 0.2s;
font-family: 'Rajdhani', sans-serif;
font-weight: 600;
}
.btn-withdraw-single:hover {
background: var(--success-color);
color: #000;
}
.filter-bar {
display: flex;
gap: 0.5rem;
@@ -628,6 +673,7 @@
</div>
<div style="display:flex;gap:0.75rem;">
<button class="sell-selected-btn" id="sellSelectedBtn" onclick="sellSelected()" disabled>💰 Продать</button>
<button class="withdraw-selected-btn" id="withdrawSelectedBtn" onclick="withdrawSelected()" disabled>💳 Вывести</button>
<button class="sell-all-btn" onclick="openBulkSellModal()">📦 Массовая</button>
</div>
</div>
@@ -658,12 +704,12 @@
data-rarity="{{ item.rarity }}"
data-type="{{ item.type }}"
data-name="{{ item.name|lower }}"
data-price="{{ item.price_rub or 100 }}">
data-price="{{ item.price_rub }}">
{% if not is_public %}
<input type="checkbox" class="item-select-checkbox"
onchange="onItemSelect(this)"
data-id="{{ item.id }}"
data-price="{{ item.price_rub or 100 }}">
data-price="{{ item.price_rub }}">
{% endif %}
<div class="skin-image-container">
<img src="{{ item.image_url or '/static/placeholder.png' }}"
@@ -681,11 +727,12 @@
<span>{{ item.wear }}</span>
</div>
<div class="skin-details" style="margin-top:3px;color:var(--success-color);">
<span>💰 {{ (item.price_rub or 100)|money }} ₽</span>
<span>💰 {{ (item.price_rub)|money }} ₽</span>
</div>
{% if not is_public %}
<div class="item-actions">
<button onclick="sellSingleItem({{ item.id }})" class="btn-sell-single">Продать</button>
<button onclick="withdrawSingleItem({{ item.id }}, {{ (item.price_rub)|int }})" class="btn-withdraw-single">Вывести</button>
</div>
{% endif %}
</div>
@@ -719,7 +766,8 @@
</div>
<div class="bulk-sell-actions">
<button class="btn btn-outline" onclick="closeBulkSellModal()">Отмена</button>
<button class="btn btn-primary" onclick="executeBulkSell()" style="background:var(--danger-color);">Продать</button>
<button class="btn btn-primary" onclick="executeBulkSell()" style="background:var(--danger-color);">💰 Продать</button>
<button class="btn btn-primary" onclick="executeBulkWithdraw()" style="background:var(--success-color);color:#000;">💳 Вывести</button>
</div>
</div>
</div>
@@ -772,9 +820,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>
@@ -826,6 +874,7 @@
const countBadge = document.getElementById('selectedCountBadge');
const priceBadge = document.getElementById('totalPriceBadge');
const sellBtn = document.getElementById('sellSelectedBtn');
const withdrawBtn = document.getElementById('withdrawSelectedBtn');
if (count > 0) {
countBadge.style.display = 'inline-block';
countBadge.textContent = count + ' выбрано';
@@ -834,10 +883,12 @@
priceBadge.style.display = 'inline-block';
priceBadge.textContent = total.toFixed(0) + ' ₽';
sellBtn.disabled = false;
withdrawBtn.disabled = false;
} else {
countBadge.style.display = 'none';
priceBadge.style.display = 'none';
sellBtn.disabled = true;
withdrawBtn.disabled = true;
}
}
@@ -846,9 +897,10 @@
const r = await fetch('/web/api/user/balance');
const d = await r.json();
if (d.success) {
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = Math.floor(d.balance) + ' ₽';
});
const el = document.getElementById('siteBalanceDisplay');
if (el) {
el.innerHTML = Math.floor(d.balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
}
}
} catch(e) {}
}
@@ -906,6 +958,64 @@
});
}
function withdrawSingleItem(inventoryId, price) {
Notify.confirm('Вывести ' + (price||'').toLocaleString() + ' ₽ на карту? (Комиссия 10%)', 'Вывод', function(confirmed) {
if (!confirmed) return;
SoundManager.click();
const formData = new FormData();
formData.append('inventory_item_id', inventoryId);
fetch('/web/api/withdraw', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.success) {
SoundManager.balanceUpdate();
Notify.success('Выведено ' + data.net.toFixed(0) + ' ₽ на карту');
const card = document.querySelector('.inventory-skin-card[data-inventory-id="' + inventoryId + '"]');
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; setTimeout(() => card.remove(), 300); }
refreshBalance();
} else {
SoundManager.error();
Notify.error(data.error || 'Ошибка при выводе');
}
})
.catch(() => { SoundManager.error(); Notify.error('Ошибка соединения'); });
});
}
function withdrawSelected() {
if (selectedItems.size === 0) return;
let total = 0;
selectedItems.forEach(id => { total += itemPrices.get(id) || 100; });
const fee = total * 0.1;
Notify.confirm('Вывести ' + selectedItems.size + ' предметов на карту?\nСумма: ' + total.toFixed(0) + ' ₽\nКомиссия: ' + fee.toFixed(0) + ' ₽\nК зачислению: ' + (total - fee).toFixed(0) + ' ₽', 'Массовый вывод', function(confirmed) {
if (!confirmed) return;
SoundManager.click();
const ids = Array.from(selectedItems);
let completed = 0, errors = 0;
ids.forEach(function(invId) {
const formData = new FormData();
formData.append('inventory_item_id', invId);
fetch('/web/api/withdraw', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.success) {
completed++;
const card = document.querySelector('.inventory-skin-card[data-inventory-id="' + invId + '"]');
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; setTimeout(() => card.remove(), 300); }
} else {
errors++;
}
if (completed + errors === ids.length) {
Notify.success('Выведено ' + completed + ' предметов' + (errors ? ', ошибок: ' + errors : ''));
selectedItems.clear();
refreshBalance();
}
})
.catch(function() { errors++; });
});
});
}
function openBulkSellModal() {
const modal = document.getElementById('bulkSellModal');
const optionsDiv = document.getElementById('bulkSellOptions');
@@ -997,6 +1107,45 @@
});
}
function executeBulkWithdraw() {
const selected = [];
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => { if (cb.value && cb.value !== 'all') selected.push(cb.value); });
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
const ids = [];
document.querySelectorAll('.inventory-skin-card').forEach(card => {
if (card.style.display !== 'none') {
if (allChecked || selected.includes(card.dataset.rarity)) {
ids.push(parseInt(card.dataset.inventoryId));
}
}
});
if (ids.length === 0) { Notify.error('Выберите хотя бы одну редкость'); return; }
let totalPrice = 0;
ids.forEach(id => { totalPrice += itemPrices.get(id) || 100; });
const fee = totalPrice * 0.1;
Notify.confirm('Вывести ' + ids.length + ' предметов на карту?\nСумма: ' + totalPrice.toFixed(0) + ' ₽\nКомиссия: ' + fee.toFixed(0) + ' ₽\nК зачислению: ' + (totalPrice - fee).toFixed(0) + ' ₽', 'Массовый вывод', function(confirmed) {
if (!confirmed) return;
let completed = 0, errors = 0;
ids.forEach(function(invId) {
const formData = new FormData();
formData.append('inventory_item_id', invId);
fetch('/web/api/withdraw', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.success) { completed++; }
else { errors++; }
if (completed + errors === ids.length) {
Notify.success('Выведено ' + completed + ' предметов' + (errors ? ', ошибок: ' + errors : ''));
refreshBalance();
closeBulkSellModal();
window.location.reload();
}
})
.catch(function() { errors++; });
});
});
}
function filterItems() {
const rf = document.getElementById('rarityFilter').value.toLowerCase();
const tf = document.getElementById('typeFilter').value.toLowerCase();
@@ -1071,7 +1220,7 @@
+ '<div class="skin-details"><span class="rarity-' + rc + '">' + (item.rarity||'') + '</span><span>' + fs + '</span></div>'
+ '<div class="skin-details" style="margin-top:3px;"><span>' + (item.type||'') + '</span><span>' + (item.wear||'') + '</span></div>'
+ '<div class="skin-details" style="margin-top:3px;color:var(--success-color);"><span>💰 ' + ((item.price_rub||100)).toLocaleString() + ' ₽</span></div>'
+ '<div class="item-actions"><button onclick="sellSingleItem(' + item.id + ')" class="btn-sell-single">Продать</button></div></div></div>';
+ '<div class="item-actions"><button onclick="sellSingleItem(' + item.id + ')" class="btn-sell-single">Продать</button><button onclick="withdrawSingleItem(' + item.id + ', ' + (item.price_rub||100) + ')" class="btn-withdraw-single">Вывести</button></div></div></div>';
});
grid.innerHTML = html;
selectedItems.clear();
@@ -1096,7 +1245,7 @@
grid.addEventListener('click', function(e) {
const card = e.target.closest('.inventory-skin-card');
if (!card) return;
if (e.target.closest('.item-select-checkbox, .btn-sell-single')) return;
if (e.target.closest('.item-select-checkbox, .btn-sell-single, .btn-withdraw-single')) return;
const invId = card.dataset.inventoryId;
if (!invId) return;
openItemCard(invId);
@@ -1155,7 +1304,10 @@
+ '<div class="ic-detail"><span class="ic-label">Тип</span><span class="ic-value">' + (item.type || 'Normal') + '</span></div>'
+ '<div class="ic-detail"><span class="ic-label">Цена</span><span class="ic-value" style="color:var(--success-color)">💰 ' + (item.price_rub || 0).toLocaleString() + ' ₽</span></div>'
+ (collHtml ? '<div class="ic-collection-title">🎨 Скины коллекции</div><div class="ic-collection-list">' + collHtml + '</div>' : '')
+ '<button class="btn-sell-single ic-sell-btn" onclick="sellSingleItem(' + item.id + '); closeItemCard();">Продать за ' + (item.price_rub || 0).toLocaleString() + ' ₽</button>'
+ '<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">'
+ '<button class="ic-sell-btn" style="flex:1;" onclick="sellSingleItem(' + item.id + '); closeItemCard();">Продать за ' + (item.price_rub || 0).toLocaleString() + ' ₽</button>'
+ '<button class="ic-sell-btn" style="flex:1;background:var(--success-color);color:#000;" onclick="withdrawSingleItem(' + item.id + ', ' + (item.price_rub || 100) + '); closeItemCard();">Вывести</button>'
+ '</div>'
+ '</div></div>';
}
+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" %}
+19 -13
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" %}
@@ -517,9 +517,10 @@
const r = await fetch('/web/api/user/balance');
const d = await r.json();
if (d.success) {
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(d.balance).toLocaleString()} ₽`;
});
const el = document.getElementById('siteBalanceDisplay');
if (el) {
el.innerHTML = Math.floor(d.balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
}
}
} catch (e) {}
}
@@ -609,6 +610,8 @@
let tickLastDeg = 0, tickCumulative = 0, tickNextThreshold = 15;
let tickLastTickCumulative = 0;
let tickControlPlayed = false;
let lastTickSoundTime = 0;
const TICK_SOUND_COOLDOWN = 60;
const tickSlowStart = data.final_angle - 90;
const tickStartTime = performance.now();
const tickMaxDuration = spinDuration * 1000 + 500;
@@ -634,16 +637,18 @@
if (delta < -180) delta += 360;
tickCumulative += delta;
tickLastDeg = deg;
if (tickCumulative >= tickNextThreshold) {
const now = performance.now();
if (tickCumulative >= tickNextThreshold && now - lastTickSoundTime >= TICK_SOUND_COOLDOWN) {
SoundManager.wheelSpin();
lastTickSoundTime = now;
tickLastTickCumulative = tickCumulative;
tickNextThreshold += tickCumulative < tickSlowStart ? 15 : 30;
}
// Контрольный тик: если до финала <10°, а последний тик был >15° назад
const remaining = data.final_angle - tickCumulative;
const gap = tickCumulative - tickLastTickCumulative;
if (remaining < 10 && remaining > 0 && gap > 15 && !tickControlPlayed) {
if (remaining < 10 && remaining > 0 && gap > 15 && !tickControlPlayed && now - lastTickSoundTime >= TICK_SOUND_COOLDOWN) {
SoundManager.wheelSpin();
lastTickSoundTime = now;
tickControlPlayed = true;
}
tickRAF = requestAnimationFrame(tickLoop);
@@ -661,6 +666,7 @@
const showResult = () => {
if (resultShown) return;
resultShown = true;
SoundManager.stopAll();
if (data.upgrade_success && data.received_item) {
document.getElementById('displayTargetImage').src = data.received_item.image_url || '/static/placeholder.png';
document.getElementById('displayTargetName').textContent = data.received_item.name.substring(0, 25);
@@ -735,7 +741,7 @@
item_id: item.item_id,
name: item.name,
rarity: item.rarity,
price_rub: item.price || item.price_rub || 100,
price_rub: item.price || item.price_rub || 0,
image_url: img,
wear: item.wear || '',
float: item.float || item.float_value || 0
@@ -753,18 +759,18 @@
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) {
data.achievements_unlocked.forEach(ach => {
const title = ach.title || ach.name || 'Достижение';
const reward = ach.reward || '';
showAchievementPopup(title, reward);
showAchievementPopup(title);
});
}
}
+92 -33
View File
@@ -1,91 +1,150 @@
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
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)
db.add(rpu)
db.commit()
from rpu import _rpu_commit_or_flush
_rpu_commit_or_flush(db)
db.refresh(rpu)
return rpu
async def get_upgrade_rpu_async(db: AsyncSession, user_id: int) -> UpgradeRPU:
result = await db.execute(select(UpgradeRPU).where(UpgradeRPU.user_id == user_id))
rpu = result.scalar_one_or_none()
if not rpu:
rpu = UpgradeRPU(user_id=user_id)
db.add(rpu)
from rpu import _rpu_commit_or_flush_async
await _rpu_commit_or_flush_async(db)
await db.refresh(rpu)
return rpu
def calculate_upgrade_probability(input_price: float, target_price: float) -> float:
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
# Green zone: [180 - half_green, 180 + half_green] — centered at 180° (bottom)
# Матчим CSS конусный градиент: from calc(180deg - prob * 3.6deg / 2)
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
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
if rpu.auto_adjust and rpu.total_attempts >= 15:
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.0, rpu.upgrade_multiplier + 0.1)
rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.1)
elif rate > 0.6:
rpu.upgrade_multiplier = max(0.5, rpu.upgrade_multiplier - 0.1)
rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.1)
from rpu import _rpu_commit_or_flush
_rpu_commit_or_flush(db)
db.commit()
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)