chore: initial commit — CS2 Simulator
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from database import Achievement, UserAchievement, ActivityFeed
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# ─── Определения достижений ─────────────────────────────────────────────────
|
||||
|
||||
ACHIEVEMENT_DEFS = [
|
||||
{
|
||||
"name": "first_case",
|
||||
"title": "Первый шаг",
|
||||
"description": "Открой свой первый кейс",
|
||||
"icon": "📦",
|
||||
"category": "cases",
|
||||
"requirement_type": "open_cases",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 100,
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"name": "case_collector_10",
|
||||
"title": "Коллекционер",
|
||||
"description": "Открой 10 кейсов",
|
||||
"icon": "📦",
|
||||
"category": "cases",
|
||||
"requirement_type": "open_cases",
|
||||
"requirement_value": 10,
|
||||
"reward_amount": 250,
|
||||
"sort_order": 2,
|
||||
},
|
||||
{
|
||||
"name": "case_collector_100",
|
||||
"title": "Заядлый коллекционер",
|
||||
"description": "Открой 100 кейсов",
|
||||
"icon": "📦",
|
||||
"category": "cases",
|
||||
"requirement_type": "open_cases",
|
||||
"requirement_value": 100,
|
||||
"reward_amount": 1000,
|
||||
"sort_order": 3,
|
||||
},
|
||||
{
|
||||
"name": "case_collector_1000",
|
||||
"title": "Одержимый",
|
||||
"description": "Открой 1000 кейсов",
|
||||
"icon": "💎",
|
||||
"category": "cases",
|
||||
"requirement_type": "open_cases",
|
||||
"requirement_value": 1000,
|
||||
"reward_amount": 5000,
|
||||
"sort_order": 4,
|
||||
},
|
||||
{
|
||||
"name": "first_contract",
|
||||
"title": "Обменщик",
|
||||
"description": "Выполни первый контракт обмена",
|
||||
"icon": "🔄",
|
||||
"category": "contracts",
|
||||
"requirement_type": "contracts",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 200,
|
||||
"sort_order": 5,
|
||||
},
|
||||
{
|
||||
"name": "contract_master",
|
||||
"title": "Мастер контрактов",
|
||||
"description": "Выполни 50 контрактов обмена",
|
||||
"icon": "🔄",
|
||||
"category": "contracts",
|
||||
"requirement_type": "contracts",
|
||||
"requirement_value": 50,
|
||||
"reward_amount": 2000,
|
||||
"sort_order": 6,
|
||||
},
|
||||
{
|
||||
"name": "first_upgrade",
|
||||
"title": "Рисковый",
|
||||
"description": "Попробуй апгрейд в первый раз",
|
||||
"icon": "🆙",
|
||||
"category": "upgrade",
|
||||
"requirement_type": "upgrade_attempts",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 100,
|
||||
"sort_order": 7,
|
||||
},
|
||||
{
|
||||
"name": "upgrade_winner",
|
||||
"title": "Везунчик",
|
||||
"description": "Выиграй 10 апгрейдов",
|
||||
"icon": "🍀",
|
||||
"category": "upgrade",
|
||||
"requirement_type": "upgrade_wins",
|
||||
"requirement_value": 10,
|
||||
"reward_amount": 1500,
|
||||
"sort_order": 8,
|
||||
},
|
||||
{
|
||||
"name": "rare_finder",
|
||||
"title": "Охотник за редким",
|
||||
"description": "Выбей предмет редкости Covert или Rare Special",
|
||||
"icon": "🔴",
|
||||
"category": "cases",
|
||||
"requirement_type": "rare_item",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 500,
|
||||
"sort_order": 9,
|
||||
},
|
||||
{
|
||||
"name": "knife_collector",
|
||||
"title": "Ножевод",
|
||||
"description": "Выбей нож или перчатки",
|
||||
"icon": "🔪",
|
||||
"category": "cases",
|
||||
"requirement_type": "knife",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 2000,
|
||||
"sort_order": 10,
|
||||
},
|
||||
{
|
||||
"name": "big_spender",
|
||||
"title": "Транжира",
|
||||
"description": "Потрать 10000 ₽ на кейсы",
|
||||
"icon": "💰",
|
||||
"category": "general",
|
||||
"requirement_type": "total_spent",
|
||||
"requirement_value": 10000,
|
||||
"reward_amount": 1000,
|
||||
"sort_order": 11,
|
||||
},
|
||||
{
|
||||
"name": "whale",
|
||||
"title": "Кит",
|
||||
"description": "Потрать 100000 ₽ на кейсы",
|
||||
"icon": "🐋",
|
||||
"category": "general",
|
||||
"requirement_type": "total_spent",
|
||||
"requirement_value": 100000,
|
||||
"reward_amount": 10000,
|
||||
"sort_order": 12,
|
||||
},
|
||||
{
|
||||
"name": "slot_winner",
|
||||
"title": "Джекпот",
|
||||
"description": "Собери 3 одинаковых символа в слот-машине",
|
||||
"icon": "🎰",
|
||||
"category": "cases",
|
||||
"requirement_type": "slot_match",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 1000,
|
||||
"sort_order": 13,
|
||||
},
|
||||
{
|
||||
"name": "rich_100k",
|
||||
"title": "Богач",
|
||||
"description": "Накопи 100000 ₽ на балансе",
|
||||
"icon": "🤑",
|
||||
"category": "general",
|
||||
"requirement_type": "balance_100k",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 5000,
|
||||
"sort_order": 14,
|
||||
},
|
||||
{
|
||||
"name": "slots_10",
|
||||
"title": "Слото-фанат",
|
||||
"description": "Сыграй 10 раундов в слот-машине",
|
||||
"icon": "🎰",
|
||||
"category": "cases",
|
||||
"requirement_type": "slot_plays",
|
||||
"requirement_value": 10,
|
||||
"reward_amount": 500,
|
||||
"sort_order": 15,
|
||||
},
|
||||
{
|
||||
"name": "crash_first",
|
||||
"title": "Крэш-тестер",
|
||||
"description": "Сыграй в Crash в первый раз",
|
||||
"icon": "💥",
|
||||
"category": "crash",
|
||||
"requirement_type": "crash_plays",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 100,
|
||||
"sort_order": 16,
|
||||
},
|
||||
{
|
||||
"name": "crash_pro",
|
||||
"title": "Крэш-профи",
|
||||
"description": "Сыграй 50 раундов в Crash",
|
||||
"icon": "💥",
|
||||
"category": "crash",
|
||||
"requirement_type": "crash_plays",
|
||||
"requirement_value": 50,
|
||||
"reward_amount": 2000,
|
||||
"sort_order": 17,
|
||||
},
|
||||
{
|
||||
"name": "crash_5x",
|
||||
"title": "Смельчак",
|
||||
"description": "Выведи деньги на множителе x5 или выше в Crash",
|
||||
"icon": "🚀",
|
||||
"category": "crash",
|
||||
"requirement_type": "crash_cashout_5x",
|
||||
"requirement_value": 1,
|
||||
"reward_amount": 1500,
|
||||
"sort_order": 18,
|
||||
},
|
||||
{
|
||||
"name": "inventory_50",
|
||||
"title": "Собиратель",
|
||||
"description": "Собери 50 предметов в инвентаре",
|
||||
"icon": "🎒",
|
||||
"category": "general",
|
||||
"requirement_type": "inventory_count",
|
||||
"requirement_value": 50,
|
||||
"reward_amount": 500,
|
||||
"sort_order": 19,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_achievements(db: Session):
|
||||
"""Создаёт достижения в БД если их ещё нет"""
|
||||
existing = db.query(Achievement).count()
|
||||
if existing > 0:
|
||||
return
|
||||
|
||||
for defn in ACHIEVEMENT_DEFS:
|
||||
ach = Achievement(**defn)
|
||||
db.add(ach)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_or_create_user_achievement(db: Session, user_id: int, achievement_id: int) -> UserAchievement:
|
||||
ua = db.query(UserAchievement).filter(
|
||||
UserAchievement.user_id == user_id,
|
||||
UserAchievement.achievement_id == achievement_id
|
||||
).first()
|
||||
if not ua:
|
||||
ua = UserAchievement(user_id=user_id, achievement_id=achievement_id)
|
||||
db.add(ua)
|
||||
db.commit()
|
||||
db.refresh(ua)
|
||||
return ua
|
||||
|
||||
|
||||
def check_achievement(db: Session, user_id: int, achievement_name: str, current_value: int) -> Optional[dict]:
|
||||
"""Проверяет и разблокирует достижение. Возвращает dict с инфой если разблокировано."""
|
||||
ach = db.query(Achievement).filter(Achievement.name == achievement_name).first()
|
||||
if not ach:
|
||||
return None
|
||||
|
||||
ua = get_or_create_user_achievement(db, user_id, ach.id)
|
||||
if ua.unlocked_at:
|
||||
return None
|
||||
|
||||
ua.progress = min(current_value, ach.requirement_value)
|
||||
|
||||
if current_value >= ach.requirement_value:
|
||||
ua.unlocked_at = datetime.utcnow()
|
||||
# Награда
|
||||
from database import User
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user:
|
||||
user.balance += ach.reward_amount
|
||||
db.commit()
|
||||
|
||||
# Запись в ленту активности
|
||||
activity = ActivityFeed(
|
||||
user_id=user_id,
|
||||
username=user.username if user else "unknown",
|
||||
activity_type="achievement",
|
||||
message=f"🏆 Получено достижение: {ach.title}",
|
||||
data_json=json.dumps({
|
||||
"achievement_name": ach.name,
|
||||
"achievement_title": ach.title,
|
||||
"achievement_icon": ach.icon,
|
||||
})
|
||||
)
|
||||
db.add(activity)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"name": ach.name,
|
||||
"title": ach.title,
|
||||
"icon": ach.icon,
|
||||
"reward": ach.reward_amount
|
||||
}
|
||||
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
def check_open_cases_achievements(db: Session, user_id: int):
|
||||
from database import CaseOpening
|
||||
count = db.query(CaseOpening).filter(CaseOpening.user_id == user_id).count()
|
||||
|
||||
results = []
|
||||
for ach_name in ["first_case", "case_collector_10", "case_collector_100", "case_collector_1000"]:
|
||||
r = check_achievement(db, user_id, ach_name, count)
|
||||
if r:
|
||||
results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
def check_contracts_achievements(db: Session, user_id: int):
|
||||
from database import Contract
|
||||
count = db.query(Contract).filter(Contract.user_id == user_id).count()
|
||||
|
||||
results = []
|
||||
for ach_name in ["first_contract", "contract_master"]:
|
||||
r = check_achievement(db, user_id, ach_name, count)
|
||||
if r:
|
||||
results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
def check_upgrade_achievements(db: Session, user_id: int):
|
||||
from database import Upgrade
|
||||
attempts = db.query(Upgrade).filter(Upgrade.user_id == user_id).count()
|
||||
wins = db.query(Upgrade).filter(Upgrade.user_id == user_id, Upgrade.success == True).count()
|
||||
|
||||
results = []
|
||||
r1 = check_achievement(db, user_id, "first_upgrade", attempts)
|
||||
if r1: results.append(r1)
|
||||
r2 = check_achievement(db, user_id, "upgrade_winner", wins)
|
||||
if r2: results.append(r2)
|
||||
return results
|
||||
|
||||
|
||||
def check_rare_item_achievement(db: Session, user_id: int, rarity: str):
|
||||
results = []
|
||||
if rarity in ("Covert", "Extraordinary", "Rare Special Item"):
|
||||
r = check_achievement(db, user_id, "rare_finder", 1)
|
||||
if r: results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
def check_knife_achievement(db: Session, user_id: int, item_data: dict):
|
||||
from backend import is_knife, is_gloves
|
||||
results = []
|
||||
if is_knife(item_data) or is_gloves(item_data):
|
||||
r = check_achievement(db, user_id, "knife_collector", 1)
|
||||
if r: results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
def check_spent_achievements(db: Session, user_id: int, total_spent: float):
|
||||
results = []
|
||||
r1 = check_achievement(db, user_id, "big_spender", int(total_spent))
|
||||
if r1: results.append(r1)
|
||||
r2 = check_achievement(db, user_id, "whale", int(total_spent))
|
||||
if r2: results.append(r2)
|
||||
return results
|
||||
|
||||
|
||||
def check_balance_achievement(db: Session, user_id: int):
|
||||
from database import User
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user and user.balance >= 100000:
|
||||
r = check_achievement(db, user_id, "rich_100k", 1)
|
||||
if r: return [r]
|
||||
return []
|
||||
|
||||
|
||||
def check_slot_match_achievement(db: Session, user_id: int):
|
||||
results = []
|
||||
r = check_achievement(db, user_id, "slot_winner", 1)
|
||||
if r: results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
def check_slot_plays_achievements(db: Session, user_id: int):
|
||||
from database import CaseOpening
|
||||
slot_count = db.query(CaseOpening).filter(
|
||||
CaseOpening.user_id == user_id,
|
||||
CaseOpening.case_name.ilike("%slot%")
|
||||
).count()
|
||||
results = []
|
||||
r = check_achievement(db, user_id, "slots_10", slot_count)
|
||||
if r: results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
def check_inventory_achievement(db: Session, user_id: int):
|
||||
from database import InventoryItem
|
||||
count = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).count()
|
||||
results = []
|
||||
r = check_achievement(db, user_id, "inventory_50", count)
|
||||
if r: results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
def get_user_achievements(db: Session, user_id: int) -> list:
|
||||
"""Возвращает все достижения пользователя с прогрессом"""
|
||||
all_ach = db.query(Achievement).order_by(Achievement.sort_order).all()
|
||||
result = []
|
||||
for ach in all_ach:
|
||||
ua = db.query(UserAchievement).filter(
|
||||
UserAchievement.user_id == user_id,
|
||||
UserAchievement.achievement_id == ach.id
|
||||
).first()
|
||||
result.append({
|
||||
"id": ach.id,
|
||||
"name": ach.name,
|
||||
"title": ach.title,
|
||||
"description": ach.description,
|
||||
"icon": ach.icon,
|
||||
"category": ach.category,
|
||||
"requirement_type": ach.requirement_type,
|
||||
"requirement_value": ach.requirement_value,
|
||||
"reward_amount": ach.reward_amount,
|
||||
"progress": ua.progress if ua else 0,
|
||||
"unlocked": ua.unlocked_at is not None if ua else False,
|
||||
"unlocked_at": ua.unlocked_at.isoformat() if ua and ua.unlocked_at else None,
|
||||
})
|
||||
return result
|
||||
@@ -0,0 +1,802 @@
|
||||
from fastapi import APIRouter, Request, Depends, Form, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, func
|
||||
from typing import Optional
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from database import get_db, User, InventoryItem, CaseOpening, Contract
|
||||
from auth import get_current_admin
|
||||
from rpu import get_user_rpu, calculate_rpu_level, get_rpu_description
|
||||
|
||||
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Загрузка конфигов
|
||||
CASES_FILE = Path("cases.json")
|
||||
MAINPAGE_FILE = Path("mainpage.json")
|
||||
|
||||
def load_cases_config():
|
||||
if CASES_FILE.exists():
|
||||
with open(CASES_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return []
|
||||
|
||||
def save_cases_config(cases):
|
||||
with open(CASES_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(cases, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def load_mainpage_config():
|
||||
if MAINPAGE_FILE.exists():
|
||||
with open(MAINPAGE_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return []
|
||||
|
||||
def save_mainpage_config(config):
|
||||
with open(MAINPAGE_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_rpu_level_for_user(user_id: int) -> float:
|
||||
"""Хелпер для шаблона"""
|
||||
db = next(get_db())
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
return calculate_rpu_level(rpu)
|
||||
|
||||
# ========== СТРАНИЦЫ ==========
|
||||
|
||||
@admin_router.get("/", response_class=HTMLResponse)
|
||||
async def admin_dashboard(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Главная страница админки"""
|
||||
|
||||
# Основная статистика
|
||||
total_users = db.query(User).count()
|
||||
total_items = db.query(InventoryItem).count()
|
||||
total_openings = db.query(CaseOpening).count()
|
||||
total_contracts = db.query(Contract).count()
|
||||
total_balance = db.query(func.sum(User.balance)).scalar() or 0
|
||||
|
||||
# Статистика РПУ
|
||||
from rpu import UserRPU
|
||||
all_rpu = db.query(UserRPU).all()
|
||||
rpu_count = len(all_rpu)
|
||||
|
||||
avg_rpu = 50.0
|
||||
lucky_count = 0
|
||||
normal_count = 0
|
||||
unlucky_count = 0
|
||||
auto_adjust_count = 0
|
||||
total_spent = 0.0
|
||||
|
||||
if rpu_count > 0:
|
||||
for r in all_rpu:
|
||||
level = calculate_rpu_level(r)
|
||||
if level > 60:
|
||||
lucky_count += 1
|
||||
elif level < 40:
|
||||
unlucky_count += 1
|
||||
else:
|
||||
normal_count += 1
|
||||
|
||||
if r.auto_adjust:
|
||||
auto_adjust_count += 1
|
||||
|
||||
total_spent += r.total_spent
|
||||
|
||||
avg_rpu = sum(calculate_rpu_level(r) for r in all_rpu) / rpu_count
|
||||
|
||||
recent_users = db.query(User).order_by(desc(User.created_at)).limit(10).all()
|
||||
|
||||
return templates.TemplateResponse("admin/dashboard.html", {
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"total_users": total_users,
|
||||
"total_items": total_items,
|
||||
"total_openings": total_openings,
|
||||
"total_contracts": total_contracts,
|
||||
"total_balance": total_balance,
|
||||
"recent_users": recent_users,
|
||||
"rpu_count": rpu_count,
|
||||
"avg_rpu": round(avg_rpu, 1),
|
||||
"lucky_count": lucky_count,
|
||||
"normal_count": normal_count,
|
||||
"unlucky_count": unlucky_count,
|
||||
"auto_adjust_count": auto_adjust_count,
|
||||
"total_spent": total_spent,
|
||||
"get_rpu_level": get_rpu_level_for_user
|
||||
})
|
||||
|
||||
@admin_router.get("/users", response_class=HTMLResponse)
|
||||
async def admin_users(
|
||||
request: Request,
|
||||
search: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Список пользователей"""
|
||||
|
||||
query = db.query(User)
|
||||
if search:
|
||||
query = query.filter(User.username.contains(search))
|
||||
|
||||
users = query.order_by(desc(User.created_at)).limit(100).all()
|
||||
|
||||
# Добавляем уровень РПУ для каждого пользователя
|
||||
users_with_rpu = []
|
||||
for u in users:
|
||||
rpu = get_user_rpu(db, u.id)
|
||||
users_with_rpu.append({
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"balance": u.balance,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at,
|
||||
"inventory_items": u.inventory_items,
|
||||
"case_openings": u.case_openings,
|
||||
"rpu_level": round(calculate_rpu_level(rpu))
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("admin/users.html", {
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"users": users_with_rpu,
|
||||
"search": search
|
||||
})
|
||||
|
||||
@admin_router.get("/user/{user_id}", response_class=HTMLResponse)
|
||||
async def admin_user_detail(
|
||||
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, "Пользователь не найден")
|
||||
|
||||
total_items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).count()
|
||||
total_openings = db.query(CaseOpening).filter(CaseOpening.user_id == user_id).count()
|
||||
total_contracts = db.query(Contract).filter(Contract.user_id == user_id).count()
|
||||
|
||||
inventory = db.query(InventoryItem).filter(
|
||||
InventoryItem.user_id == user_id
|
||||
).order_by(desc(InventoryItem.obtained_at)).limit(50).all()
|
||||
|
||||
return templates.TemplateResponse("admin/user_detail.html", {
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"target_user": target_user,
|
||||
"total_items": total_items,
|
||||
"total_openings": total_openings,
|
||||
"total_contracts": total_contracts,
|
||||
"inventory": inventory
|
||||
})
|
||||
|
||||
@admin_router.get("/cases", response_class=HTMLResponse)
|
||||
async def admin_cases(
|
||||
request: Request,
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
from frontend import ALL_ITEMS
|
||||
|
||||
cases = load_cases_config()
|
||||
mainpage = load_mainpage_config()
|
||||
|
||||
for case in cases:
|
||||
if not isinstance(case.get('items'), list):
|
||||
case['items'] = []
|
||||
|
||||
all_items = []
|
||||
for i, item in enumerate(ALL_ITEMS[:500]):
|
||||
all_items.append({
|
||||
"id": item.get("_id", i),
|
||||
"name": item.get("market_hash_name", "Unknown"),
|
||||
"rarity": item.get("rarity", "Unknown"),
|
||||
"image_url": item.get("image_url", "")
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("admin/cases.html", {
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"cases": cases,
|
||||
"mainpage": mainpage,
|
||||
"all_items": all_items,
|
||||
"cases_config": {c['name']: c for c in cases}
|
||||
})
|
||||
|
||||
# ========== API ПОЛЬЗОВАТЕЛИ ==========
|
||||
|
||||
@admin_router.post("/api/user/{user_id}/balance")
|
||||
async def admin_set_balance(
|
||||
user_id: int,
|
||||
amount: float = Form(...),
|
||||
operation: str = Form(...),
|
||||
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:
|
||||
return JSONResponse(status_code=404, content={"error": "Пользователь не найден"})
|
||||
|
||||
old_balance = target_user.balance
|
||||
|
||||
if operation == "add":
|
||||
target_user.balance += amount
|
||||
message = f"Добавлено {amount:.2f} ₽"
|
||||
elif operation == "remove":
|
||||
target_user.balance = max(0, target_user.balance - amount)
|
||||
message = f"Снято {amount:.2f} ₽"
|
||||
else:
|
||||
target_user.balance = amount
|
||||
message = f"Установлен баланс {amount:.2f} ₽"
|
||||
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"old_balance": old_balance,
|
||||
"new_balance": target_user.balance,
|
||||
"message": message
|
||||
})
|
||||
|
||||
@admin_router.post("/api/user/{user_id}/give-item")
|
||||
async def admin_give_item(
|
||||
user_id: int,
|
||||
item_id: int = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
from frontend import ALL_ITEMS, generate_item_float
|
||||
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
return JSONResponse(status_code=404, content={"error": "Пользователь не найден"})
|
||||
|
||||
item_data = None
|
||||
for idx, item in enumerate(ALL_ITEMS):
|
||||
if item.get("_id", idx) == item_id:
|
||||
item_data = item
|
||||
break
|
||||
|
||||
if not item_data:
|
||||
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
|
||||
|
||||
item_float = generate_item_float(item_data.get("rarity", "Unknown"))
|
||||
|
||||
inventory_item = InventoryItem(
|
||||
user_id=user_id,
|
||||
item_id=item_id,
|
||||
market_hash_name=item_data.get("market_hash_name", "Unknown"),
|
||||
rarity=item_data.get("rarity", "Unknown"),
|
||||
wear=item_data.get("wear", "Unknown"),
|
||||
float_value=item_float,
|
||||
type=item_data.get("type", "Normal"),
|
||||
obtained_from="admin"
|
||||
)
|
||||
db.add(inventory_item)
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Предмет '{item_data.get('market_hash_name')}' выдан пользователю {target_user.username}"
|
||||
})
|
||||
|
||||
@admin_router.post("/api/user/{user_id}/toggle-ban")
|
||||
async def admin_toggle_ban(
|
||||
user_id: int,
|
||||
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:
|
||||
return JSONResponse(status_code=404, content={"error": "Пользователь не найден"})
|
||||
|
||||
target_user.is_banned = not target_user.is_banned
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"is_banned": target_user.is_banned,
|
||||
"message": f"Пользователь {'забанен' if target_user.is_banned else 'разбанен'}"
|
||||
})
|
||||
|
||||
@admin_router.post("/api/user/{user_id}/toggle-admin")
|
||||
async def admin_toggle_admin(
|
||||
user_id: int,
|
||||
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:
|
||||
return JSONResponse(status_code=404, content={"error": "Пользователь не найден"})
|
||||
|
||||
if target_user.id == admin.id:
|
||||
return JSONResponse(status_code=400, content={"error": "Нельзя изменить свой статус"})
|
||||
|
||||
target_user.is_admin = not target_user.is_admin
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"is_admin": target_user.is_admin,
|
||||
"message": f"Статус администратора {'включен' if target_user.is_admin else 'выключен'}"
|
||||
})
|
||||
|
||||
@admin_router.post("/api/user/{user_id}/delete-item/{inventory_id}")
|
||||
async def admin_delete_item(
|
||||
user_id: int,
|
||||
inventory_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
item = db.query(InventoryItem).filter(
|
||||
InventoryItem.id == inventory_id,
|
||||
InventoryItem.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not item:
|
||||
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
|
||||
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({"success": True, "message": "Предмет удален"})
|
||||
|
||||
# ========== API РПУ ==========
|
||||
|
||||
@admin_router.get("/api/user/{user_id}/rpu")
|
||||
async def admin_get_user_rpu(
|
||||
user_id: int,
|
||||
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:
|
||||
return JSONResponse(status_code=404, content={"error": "Пользователь не найден"})
|
||||
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"username": target_user.username,
|
||||
"multipliers": {
|
||||
"consumer": rpu.consumer_multiplier,
|
||||
"industrial": rpu.industrial_multiplier,
|
||||
"mil_spec": rpu.mil_spec_multiplier,
|
||||
"restricted": rpu.restricted_multiplier,
|
||||
"classified": rpu.classified_multiplier,
|
||||
"covert": rpu.covert_multiplier,
|
||||
"rare_special": rpu.rare_special_multiplier,
|
||||
},
|
||||
"luck_multiplier": rpu.luck_multiplier,
|
||||
"auto_adjust": rpu.auto_adjust,
|
||||
"rpu_level": calculate_rpu_level(rpu),
|
||||
"stats": {
|
||||
"total_spent": rpu.total_spent,
|
||||
"total_opened": rpu.total_opened
|
||||
}
|
||||
}
|
||||
|
||||
@admin_router.post("/api/user/{user_id}/rpu/update")
|
||||
async def admin_update_user_rpu(
|
||||
user_id: int,
|
||||
consumer: float = Form(1.0),
|
||||
industrial: float = Form(1.0),
|
||||
mil_spec: float = Form(1.0),
|
||||
restricted: float = Form(1.0),
|
||||
classified: float = Form(1.0),
|
||||
covert: float = Form(1.0),
|
||||
rare_special: float = Form(1.0),
|
||||
luck: float = Form(1.0),
|
||||
auto_adjust: bool = Form(False),
|
||||
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:
|
||||
return JSONResponse(status_code=404, content={"error": "Пользователь не найден"})
|
||||
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
|
||||
rpu.consumer_multiplier = max(0.1, min(3.0, consumer))
|
||||
rpu.industrial_multiplier = max(0.1, min(3.0, industrial))
|
||||
rpu.mil_spec_multiplier = max(0.1, min(3.0, mil_spec))
|
||||
rpu.restricted_multiplier = max(0.1, min(3.0, restricted))
|
||||
rpu.classified_multiplier = max(0.1, min(3.0, classified))
|
||||
rpu.covert_multiplier = max(0.1, min(3.0, covert))
|
||||
rpu.rare_special_multiplier = max(0.1, min(3.0, rare_special))
|
||||
rpu.luck_multiplier = max(0.1, min(3.0, luck))
|
||||
rpu.auto_adjust = auto_adjust
|
||||
rpu.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Настройки РПУ для {target_user.username} обновлены",
|
||||
"rpu_level": calculate_rpu_level(rpu)
|
||||
})
|
||||
|
||||
@admin_router.post("/api/user/{user_id}/rpu/preset")
|
||||
async def admin_set_rpu_preset(
|
||||
user_id: int,
|
||||
preset: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
# Теперь: низкий множитель = высокий РПУ (не везёт)
|
||||
# высокий множитель = низкий РПУ (везёт)
|
||||
presets = {
|
||||
"lucky": {"all": 2.0, "luck": 1.8}, # ~10% РПУ - очень везёт
|
||||
"normal": {"all": 1.0, "luck": 1.0}, # ~50% РПУ - обычно
|
||||
"unlucky": {"all": 0.6, "luck": 0.7}, # ~75% РПУ - не везёт
|
||||
"very_unlucky": {"all": 0.3, "luck": 0.4} # ~95% РПУ - проклят
|
||||
}
|
||||
|
||||
if preset not in presets:
|
||||
return JSONResponse(status_code=400, content={"error": "Неизвестный пресет"})
|
||||
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
return JSONResponse(status_code=404, content={"error": "Пользователь не найден"})
|
||||
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
p = presets[preset]
|
||||
|
||||
rpu.consumer_multiplier = p["all"]
|
||||
rpu.industrial_multiplier = p["all"]
|
||||
rpu.mil_spec_multiplier = p["all"]
|
||||
rpu.restricted_multiplier = p["all"]
|
||||
rpu.classified_multiplier = p["all"]
|
||||
rpu.covert_multiplier = p["all"]
|
||||
rpu.rare_special_multiplier = p["all"]
|
||||
rpu.luck_multiplier = p["luck"]
|
||||
rpu.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
level = calculate_rpu_level(rpu)
|
||||
desc = get_rpu_description(level)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Пресет '{preset}' применен. Уровень РПУ: {level:.0f}% ({desc})"
|
||||
})
|
||||
|
||||
# ========== API КЕЙСЫ ==========
|
||||
|
||||
@admin_router.post("/api/cases/create")
|
||||
async def admin_create_case(
|
||||
name: str = Form(...),
|
||||
display_name: str = Form(...),
|
||||
price: float = Form(...),
|
||||
description: str = Form(""),
|
||||
image_url: str = Form(""),
|
||||
items: str = Form(...),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
cases = load_cases_config()
|
||||
|
||||
for case in cases:
|
||||
if case['name'] == name:
|
||||
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
|
||||
|
||||
try:
|
||||
item_ids = json.loads(items)
|
||||
except:
|
||||
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
|
||||
|
||||
new_case = {
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"price_open": price,
|
||||
"items": item_ids,
|
||||
"image_url": image_url,
|
||||
"description": description
|
||||
}
|
||||
|
||||
cases.append(new_case)
|
||||
save_cases_config(cases)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"case": new_case,
|
||||
"message": f"Кейс '{display_name}' создан"
|
||||
})
|
||||
|
||||
@admin_router.post("/api/cases/update/{case_name}")
|
||||
async def admin_update_case(
|
||||
case_name: str,
|
||||
display_name: str = Form(...),
|
||||
price: float = Form(...),
|
||||
description: str = Form(""),
|
||||
image_url: str = Form(""),
|
||||
items: str = Form(...),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
cases = load_cases_config()
|
||||
|
||||
for case in cases:
|
||||
if case['name'] == case_name:
|
||||
try:
|
||||
item_ids = json.loads(items)
|
||||
except:
|
||||
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
|
||||
|
||||
case['display_name'] = display_name
|
||||
case['price_open'] = price
|
||||
case['description'] = description
|
||||
case['image_url'] = image_url
|
||||
case['items'] = item_ids
|
||||
|
||||
save_cases_config(cases)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"case": case,
|
||||
"message": f"Кейс '{display_name}' обновлен"
|
||||
})
|
||||
|
||||
return JSONResponse(status_code=404, content={"error": "Кейс не найден"})
|
||||
|
||||
@admin_router.post("/api/cases/delete/{case_name}")
|
||||
async def admin_delete_case(
|
||||
case_name: str,
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
cases = load_cases_config()
|
||||
cases = [c for c in cases if c['name'] != case_name]
|
||||
save_cases_config(cases)
|
||||
|
||||
mainpage = load_mainpage_config()
|
||||
for section in mainpage:
|
||||
section['cases'] = [c for c in section['cases'] if c != case_name]
|
||||
save_mainpage_config(mainpage)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Кейс '{case_name}' удален"
|
||||
})
|
||||
|
||||
@admin_router.post("/api/mainpage/update")
|
||||
async def admin_update_mainpage(
|
||||
config: str = Form(...),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
try:
|
||||
new_config = json.loads(config)
|
||||
except:
|
||||
return JSONResponse(status_code=400, content={"error": "Неверный формат JSON"})
|
||||
|
||||
save_mainpage_config(new_config)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": "Конфигурация главной страницы обновлена"
|
||||
})
|
||||
|
||||
@admin_router.get("/api/items/search")
|
||||
async def admin_search_items(
|
||||
q: str = "",
|
||||
min_price: float = 0,
|
||||
max_price: float = 0,
|
||||
limit: int = 200
|
||||
):
|
||||
"""Поиск предметов для добавления в кейс / апгрейда"""
|
||||
from frontend import ALL_ITEMS
|
||||
|
||||
query = q.lower().strip()
|
||||
|
||||
results = []
|
||||
|
||||
def accept_item(item, idx):
|
||||
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
|
||||
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"),
|
||||
"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) >= 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:
|
||||
break
|
||||
else:
|
||||
for idx, item in enumerate(ALL_ITEMS):
|
||||
if not accept_item(item, idx):
|
||||
continue
|
||||
name = item.get("market_hash_name", "").lower()
|
||||
weapon = item.get("weapon", "").lower()
|
||||
pattern = item.get("pattern", "").lower()
|
||||
|
||||
if query in name or query in weapon or query in pattern:
|
||||
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:
|
||||
break
|
||||
|
||||
return results[:200]
|
||||
|
||||
|
||||
@admin_router.post("/api/mainpage/section/add")
|
||||
async def admin_add_mainpage_section(
|
||||
tab: str = Form(...),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Добавить новую секцию на главную"""
|
||||
mainpage = load_mainpage_config()
|
||||
|
||||
# Проверяем, нет ли уже такой секции
|
||||
for section in mainpage:
|
||||
if section['tab'] == tab:
|
||||
return JSONResponse(status_code=400, content={"error": "Секция уже существует"})
|
||||
|
||||
mainpage.append({
|
||||
"tab": tab,
|
||||
"cases": []
|
||||
})
|
||||
save_mainpage_config(mainpage)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Секция '{tab}' добавлена"
|
||||
})
|
||||
|
||||
@admin_router.post("/api/mainpage/section/delete")
|
||||
async def admin_delete_mainpage_section(
|
||||
tab: str = Form(...),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Удалить секцию с главной"""
|
||||
mainpage = load_mainpage_config()
|
||||
mainpage = [s for s in mainpage if s['tab'] != tab]
|
||||
save_mainpage_config(mainpage)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Секция '{tab}' удалена"
|
||||
})
|
||||
|
||||
@admin_router.post("/api/mainpage/section/add-case")
|
||||
async def admin_add_case_to_section(
|
||||
tab: str = Form(...),
|
||||
case_name: str = Form(...),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Добавить кейс в секцию"""
|
||||
mainpage = load_mainpage_config()
|
||||
|
||||
for section in mainpage:
|
||||
if section['tab'] == tab:
|
||||
if case_name not in section['cases']:
|
||||
section['cases'].append(case_name)
|
||||
save_mainpage_config(mainpage)
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Кейс '{case_name}' добавлен в секцию '{tab}'"
|
||||
})
|
||||
else:
|
||||
return JSONResponse(status_code=400, content={"error": "Кейс уже в секции"})
|
||||
|
||||
return JSONResponse(status_code=404, content={"error": "Секция не найдена"})
|
||||
|
||||
@admin_router.post("/api/mainpage/section/remove-case")
|
||||
async def admin_remove_case_from_section(
|
||||
tab: str = Form(...),
|
||||
case_name: str = Form(...),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Удалить кейс из секции"""
|
||||
mainpage = load_mainpage_config()
|
||||
|
||||
for section in mainpage:
|
||||
if section['tab'] == tab:
|
||||
if case_name in section['cases']:
|
||||
section['cases'].remove(case_name)
|
||||
save_mainpage_config(mainpage)
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Кейс '{case_name}' удален из секции '{tab}'"
|
||||
})
|
||||
|
||||
return JSONResponse(status_code=404, content={"error": "Секция или кейс не найдены"})
|
||||
|
||||
@admin_router.get("/api/cases/list")
|
||||
async def admin_get_cases_list(
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Получить список всех кейсов для выбора"""
|
||||
cases = load_cases_config()
|
||||
return [{"name": c['name'], "display_name": c.get('display_name', c['name'])} for c in cases]
|
||||
|
||||
@admin_router.get("/api/items/{item_id}")
|
||||
async def admin_get_item(
|
||||
item_id: int,
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Получить информацию о предмете по ID"""
|
||||
from frontend import ALL_ITEMS
|
||||
|
||||
for idx, item in enumerate(ALL_ITEMS):
|
||||
if item.get("_id", idx) == item_id:
|
||||
return {
|
||||
"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", ""),
|
||||
"type": item.get("type", "Normal"),
|
||||
"wear": item.get("wear", "")
|
||||
}
|
||||
|
||||
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
|
||||
|
||||
|
||||
@admin_router.post("/api/reset-password/{user_id}")
|
||||
async def admin_reset_password(
|
||||
user_id: int,
|
||||
new_password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Сброс пароля пользователя (только для админов)"""
|
||||
from auth import get_password_hash
|
||||
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
return JSONResponse(status_code=404, content={"error": "Пользователь не найден"})
|
||||
|
||||
target_user.hashed_password = get_password_hash(new_password)
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Пароль для {target_user.username} изменен"
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
from datetime import datetime, timedelta
|
||||
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.orm import Session
|
||||
from database import get_db, User
|
||||
import os
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 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: # <-- Добавлена проверка
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
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)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
async def get_current_user_from_token(token: str, db: Session) -> Optional[User]:
|
||||
"""Получает пользователя из JWT токена"""
|
||||
if not token: # <-- Добавлена проверка
|
||||
return None
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
return None
|
||||
|
||||
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]:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token: # <-- Исправлено: сразу возвращаем None
|
||||
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:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
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"},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
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)
|
||||
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):
|
||||
return None
|
||||
return user
|
||||
|
||||
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
|
||||
+916
@@ -0,0 +1,916 @@
|
||||
# main.py
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel, field_validator, validator
|
||||
from typing import List, Optional, Dict, Any
|
||||
import json
|
||||
from pathlib import Path
|
||||
from collections import Counter, defaultdict
|
||||
import random
|
||||
import sys
|
||||
from tqdm import tqdm
|
||||
|
||||
app = FastAPI(
|
||||
title="CS2 Trade-Up Contract Simulator API",
|
||||
description="Симуляция контрактов 10→1 по механике CS2",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
DATA_FILE = Path("skins.json")
|
||||
CUSTOM_DATA_FILE = Path("custom_items.json")
|
||||
|
||||
# ─── Загрузка кастомных предметов ─────────────────────────────────────────
|
||||
def load_custom_items() -> List[dict]:
|
||||
"""Загружает кастомные предметы из отдельного файла"""
|
||||
custom_items = []
|
||||
if CUSTOM_DATA_FILE.exists():
|
||||
try:
|
||||
with CUSTOM_DATA_FILE.open("r", encoding="utf-8") as f:
|
||||
custom_items = json.load(f)
|
||||
print(f"Загружено {len(custom_items)} кастомных предметов")
|
||||
except Exception as e:
|
||||
print(f"Ошибка загрузки кастомных предметов: {e}")
|
||||
else:
|
||||
# Создаем пример файла с кастомными предметами
|
||||
example_custom = [
|
||||
{
|
||||
"market_hash_name": "★ Dildo | Fade (Factory New)",
|
||||
"weapon": "Dildo",
|
||||
"pattern": "Fade",
|
||||
"rarity": "Covert",
|
||||
"collection": "",
|
||||
"type": "Normal",
|
||||
"wear": "Factory New",
|
||||
"wear_range": [0.00, 0.07],
|
||||
"mid_float_used": 0.01,
|
||||
"doppler_phase": None,
|
||||
"price_usd": 1000.0,
|
||||
"price_rub": 100000.0,
|
||||
"price_source": "custom",
|
||||
"image_url": "",
|
||||
"crates_names": ["create: dildo_case"],
|
||||
"float_range": [0.00, 0.07],
|
||||
"can_be_assigned_from": ["create: dildo_case"],
|
||||
"is_craftable": True
|
||||
},
|
||||
{
|
||||
"market_hash_name": "★ Dildo | Crimson Web (Minimal Wear)",
|
||||
"weapon": "Dildo",
|
||||
"pattern": "Crimson Web",
|
||||
"rarity": "Covert",
|
||||
"collection": "",
|
||||
"type": "Normal",
|
||||
"wear": "Minimal Wear",
|
||||
"wear_range": [0.07, 0.15],
|
||||
"mid_float_used": 0.10,
|
||||
"doppler_phase": None,
|
||||
"price_usd": 800.0,
|
||||
"price_rub": 80000.0,
|
||||
"price_source": "custom",
|
||||
"image_url": "",
|
||||
"crates_names": ["create: dildo_case"],
|
||||
"float_range": [0.07, 0.15],
|
||||
"can_be_assigned_from": ["create: dildo_case"],
|
||||
"is_craftable": True
|
||||
},
|
||||
{
|
||||
"market_hash_name": "Dildo | Blue Gem (Factory New)",
|
||||
"weapon": "Dildo",
|
||||
"pattern": "Blue Gem",
|
||||
"rarity": "Classified",
|
||||
"collection": "",
|
||||
"type": "Normal",
|
||||
"wear": "Factory New",
|
||||
"wear_range": [0.00, 0.07],
|
||||
"mid_float_used": 0.01,
|
||||
"doppler_phase": None,
|
||||
"price_usd": 500.0,
|
||||
"price_rub": 50000.0,
|
||||
"price_source": "custom",
|
||||
"image_url": "",
|
||||
"crates_names": ["create: dildo_case"],
|
||||
"float_range": [0.00, 0.07],
|
||||
"can_be_assigned_from": ["create: dildo_case"],
|
||||
"is_craftable": True
|
||||
}
|
||||
]
|
||||
try:
|
||||
with CUSTOM_DATA_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(example_custom, f, ensure_ascii=False, indent=2)
|
||||
print(f"Создан пример файла кастомных предметов: {CUSTOM_DATA_FILE}")
|
||||
custom_items = example_custom
|
||||
except Exception as e:
|
||||
print(f"Не удалось создать файл кастомных предметов: {e}")
|
||||
|
||||
return custom_items
|
||||
|
||||
# ─── Загрузка основных данных ─────────────────────────────────────────────
|
||||
def load_base_items() -> List[dict]:
|
||||
"""Загружает основные предметы из skins.json"""
|
||||
try:
|
||||
with DATA_FILE.open("r", encoding="utf-8") as f:
|
||||
items = json.load(f)
|
||||
print(f"Загружено {len(items):,} основных записей")
|
||||
return items
|
||||
except FileNotFoundError:
|
||||
print(f"Критическая ошибка: файл {DATA_FILE} не найден")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Ошибка чтения файла данных: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Загружаем все предметы (основные + кастомные)
|
||||
BASE_ITEMS = load_base_items()
|
||||
CUSTOM_ITEMS = load_custom_items()
|
||||
|
||||
# Объединяем предметы: сначала основные, потом кастомные
|
||||
ALL_ITEMS = BASE_ITEMS + CUSTOM_ITEMS
|
||||
print(f"Всего предметов после объединения: {len(ALL_ITEMS):,}")
|
||||
|
||||
ITEM_BY_ID: Dict[int, dict] = {i: item for i, item in enumerate(ALL_ITEMS)}
|
||||
|
||||
# ─── Глобальные кэши / индексы ──────────────────────────────────────────────
|
||||
ALL_ITEMS_WITH_ID: List[dict] = []
|
||||
ITEMS_BY_TYPE: Dict[str, List[dict]] = defaultdict(list)
|
||||
ITEMS_BY_RARITY: Dict[str, List[dict]] = defaultdict(list)
|
||||
NAME_LOWER_TO_IDS: Dict[str, List[int]] = defaultdict(list)
|
||||
COLLECTIONS_INDEX: Dict[str, List[int]] = {}
|
||||
CAN_BE_ASSIGNED_FROM_INDEX: Dict[str, List[int]] = defaultdict(list)
|
||||
|
||||
# ─── Проверка типа предмета ───────────────────────────────────────────────
|
||||
def is_knife(item: dict) -> bool:
|
||||
"""Проверяет, является ли предмет ножом"""
|
||||
weapon = item.get("weapon", "")
|
||||
return "Knife" in weapon or "Bayonet" in weapon
|
||||
|
||||
def is_gloves(item: dict) -> bool:
|
||||
"""Проверяет, являются ли предмет перчатками"""
|
||||
weapon = item.get("weapon", "")
|
||||
return "Wraps" in weapon or "Gloves" in weapon or "Hand" in weapon
|
||||
|
||||
def is_rare_special_item(item: dict) -> bool:
|
||||
"""Проверяет, является ли предмет Rare Special Item (ножи и перчатки)"""
|
||||
return is_knife(item) or is_gloves(item)
|
||||
|
||||
# ─── Редкости ──────────────────────────────────────────────────────────────
|
||||
RARITY_ORDER = {
|
||||
"Consumer Grade": 0,
|
||||
"Industrial Grade": 1,
|
||||
"Mil-Spec": 2,
|
||||
"Mil-Spec Grade": 2,
|
||||
"Restricted": 3,
|
||||
"Classified": 4,
|
||||
"Covert": 5, # Базовый уровень для Covert
|
||||
"Rare Special Item": 6, # Уровень для ножей/перчаток (конвертируется из Covert)
|
||||
"Extraordinary": 7, # Для некоторых перчаток
|
||||
"Contraband": 8,
|
||||
"Unknown": -1,
|
||||
}
|
||||
|
||||
def get_rarity_level(rarity: str | None, item: Optional[dict] = None) -> int:
|
||||
"""
|
||||
Возвращает числовой уровень редкости.
|
||||
Для ножей и перчаток с редкостью Covert возвращает уровень Rare Special Item (6)
|
||||
"""
|
||||
if not rarity:
|
||||
return -1
|
||||
|
||||
rarity = rarity.strip()
|
||||
|
||||
# Если это Extraordinary - это уже правильный уровень
|
||||
if rarity == "Extraordinary":
|
||||
return RARITY_ORDER["Extraordinary"]
|
||||
|
||||
# Если передан предмет и это Covert
|
||||
if item and rarity == "Covert":
|
||||
# Проверяем, является ли предмет ножом или перчатками
|
||||
if is_rare_special_item(item):
|
||||
return RARITY_ORDER["Rare Special Item"]
|
||||
else:
|
||||
return RARITY_ORDER["Covert"]
|
||||
|
||||
return RARITY_ORDER.get(rarity, -1)
|
||||
|
||||
# ─── Модели ────────────────────────────────────────────────────────────────
|
||||
class ItemShort(BaseModel):
|
||||
id: int
|
||||
market_hash_name: str
|
||||
rarity: Optional[str] = None
|
||||
collection: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
price_rub: Optional[float] = None
|
||||
price_usd: Optional[float] = None
|
||||
|
||||
class ContractSubmitRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
|
||||
@field_validator("item_ids")
|
||||
@classmethod
|
||||
def check_length(cls, v):
|
||||
if len(v) != 10:
|
||||
raise ValueError("Нужно ровно 10 предметов")
|
||||
return v
|
||||
|
||||
class TradeUpSimulationResult(BaseModel):
|
||||
success: bool
|
||||
input_items: List[ItemShort]
|
||||
received_item: Optional[ItemShort] = None
|
||||
received_float: Optional[float] = None
|
||||
probability: Optional[float] = None
|
||||
message: str
|
||||
|
||||
class CollectionItemBase(BaseModel):
|
||||
base_name: str
|
||||
rarity: str
|
||||
item_ids_normal: List[int]
|
||||
item_ids_stattrak: List[int]
|
||||
item_ids_souvenir: List[int]
|
||||
count_variants: int
|
||||
|
||||
class CollectionDetail(BaseModel):
|
||||
collection: str
|
||||
total_unique_skins: int
|
||||
items: List[CollectionItemBase]
|
||||
|
||||
# ─── Вспомогательные ───────────────────────────────────────────────────────
|
||||
def get_item(idx: int) -> Optional[dict]:
|
||||
return ITEM_BY_ID.get(idx)
|
||||
|
||||
def is_final_in_collection(item: dict) -> bool:
|
||||
"""Проверяет, есть ли предметы выше редкостью в той же коллекции/источнике"""
|
||||
my_level = get_rarity_level(item.get("rarity"), item)
|
||||
|
||||
# Собираем все источники, откуда мог выпасть предмет
|
||||
sources = []
|
||||
|
||||
# Из collection
|
||||
coll = item.get("collection", "").strip()
|
||||
if coll and coll != "Без коллекции":
|
||||
sources.append(coll)
|
||||
|
||||
# Из can_be_assigned_from
|
||||
assigned = item.get("can_be_assigned_from", [])
|
||||
if isinstance(assigned, list):
|
||||
sources.extend(assigned)
|
||||
elif isinstance(assigned, str) and assigned:
|
||||
sources.append(assigned)
|
||||
|
||||
# Из crates_names (кейсы)
|
||||
crates = item.get("crates_names", [])
|
||||
if crates and isinstance(crates, list):
|
||||
for crate in crates:
|
||||
sources.append(f"Case: {crate}")
|
||||
|
||||
# Проверяем каждый источник
|
||||
for source in sources:
|
||||
# Проверяем в COLLECTIONS_INDEX
|
||||
for idx in COLLECTIONS_INDEX.get(source, []):
|
||||
other = get_item(idx)
|
||||
if other:
|
||||
other_level = get_rarity_level(other.get("rarity"), other)
|
||||
if other_level > my_level:
|
||||
return False
|
||||
|
||||
# Проверяем в CAN_BE_ASSIGNED_FROM_INDEX
|
||||
for idx in CAN_BE_ASSIGNED_FROM_INDEX.get(source, []):
|
||||
other = get_item(idx)
|
||||
if other:
|
||||
other_level = get_rarity_level(other.get("rarity"), other)
|
||||
if other_level > my_level:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def can_be_upgraded(items: List[dict]) -> tuple[bool, str]:
|
||||
"""Проверяет, можно ли использовать эти предметы в контракте"""
|
||||
if len(items) != 10:
|
||||
return False, "Нужно ровно 10 предметов"
|
||||
|
||||
# Проверяем is_craftable
|
||||
for item in items:
|
||||
if not item.get("is_craftable", True):
|
||||
return False, f"Предмет нельзя использовать в крафте: {item.get('market_hash_name', '???')}"
|
||||
|
||||
# Проверяем, что ни один предмет не является финальным
|
||||
for item in items:
|
||||
if is_final_in_collection(item):
|
||||
return False, f"Нельзя использовать финальный предмет коллекции: {item.get('market_hash_name', '???')}"
|
||||
|
||||
# Проверяем тип (Normal/StatTrak/Souvenir)
|
||||
types = {it.get("type") for it in items if it.get("type")}
|
||||
if len(types) != 1:
|
||||
return False, "Все предметы должны быть одного типа"
|
||||
|
||||
# Проверяем редкость с учетом типа предмета
|
||||
rarities = set()
|
||||
for it in items:
|
||||
level = get_rarity_level(it.get("rarity"), it)
|
||||
rarities.add(level)
|
||||
|
||||
if len(rarities) != 1:
|
||||
return False, "Все предметы должны быть одной редкости"
|
||||
|
||||
return True, ""
|
||||
|
||||
def extract_base_name(market_hash_name: str) -> str:
|
||||
"""Извлекает базовое имя скина без StatTrak, Souvenir и качества"""
|
||||
name = market_hash_name.strip()
|
||||
|
||||
# Убираем префиксы
|
||||
for prefix in ["StatTrak™ ", "Souvenir "]:
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix):].strip()
|
||||
|
||||
# Убираем качество в скобках
|
||||
if " (" in name:
|
||||
name = name.split(" (", 1)[0].strip()
|
||||
|
||||
return name
|
||||
|
||||
def build_indexes():
|
||||
"""Строит все индексы"""
|
||||
global COLLECTIONS_INDEX, CAN_BE_ASSIGNED_FROM_INDEX
|
||||
|
||||
coll = defaultdict(list)
|
||||
assigned_from = defaultdict(list)
|
||||
|
||||
for idx, item in ITEM_BY_ID.items():
|
||||
# Индекс коллекций
|
||||
c = item.get("collection", "").strip()
|
||||
if c and c != "Без коллекции":
|
||||
coll[c].append(idx)
|
||||
|
||||
# Индекс can_be_assigned_from
|
||||
assigned = item.get("can_be_assigned_from", [])
|
||||
if isinstance(assigned, list):
|
||||
for source in assigned:
|
||||
assigned_from[source].append(idx)
|
||||
elif isinstance(assigned, str) and assigned:
|
||||
assigned_from[assigned].append(idx)
|
||||
|
||||
# Индекс кейсов (crates_names)
|
||||
crates = item.get("crates_names", [])
|
||||
if crates and isinstance(crates, list):
|
||||
for crate in crates:
|
||||
collection_name = f"Case: {crate}"
|
||||
coll[collection_name].append(idx)
|
||||
|
||||
COLLECTIONS_INDEX = dict(coll)
|
||||
CAN_BE_ASSIGNED_FROM_INDEX = dict(assigned_from)
|
||||
|
||||
def load_and_index_data():
|
||||
"""Индексирует все данные для быстрого поиска"""
|
||||
global ALL_ITEMS_WITH_ID, ITEMS_BY_TYPE, ITEMS_BY_RARITY, NAME_LOWER_TO_IDS
|
||||
|
||||
ALL_ITEMS_WITH_ID = []
|
||||
ITEMS_BY_TYPE.clear()
|
||||
ITEMS_BY_RARITY.clear()
|
||||
NAME_LOWER_TO_IDS.clear()
|
||||
|
||||
print("Создание индексов...")
|
||||
for i in tqdm(range(len(ALL_ITEMS)), desc="Индексация"):
|
||||
item = ALL_ITEMS[i]
|
||||
item_with_id = {**item, "id": i}
|
||||
ALL_ITEMS_WITH_ID.append(item_with_id)
|
||||
ITEM_BY_ID[i] = item
|
||||
|
||||
# По типу
|
||||
t = item.get("type", "Normal")
|
||||
ITEMS_BY_TYPE[t].append(item_with_id)
|
||||
|
||||
# По редкости
|
||||
r = item.get("rarity", "Unknown")
|
||||
ITEMS_BY_RARITY[r].append(item_with_id)
|
||||
|
||||
# Поиск по имени
|
||||
name = item.get("market_hash_name", "").lower().strip()
|
||||
if name:
|
||||
NAME_LOWER_TO_IDS[name].append(i)
|
||||
|
||||
base_name = extract_base_name(item.get("market_hash_name", "")).lower().strip()
|
||||
if base_name and base_name != name:
|
||||
NAME_LOWER_TO_IDS[base_name].append(i)
|
||||
|
||||
# Строим индексы коллекций
|
||||
build_indexes()
|
||||
|
||||
print(f"Готово. Уникальных имён для поиска: {len(NAME_LOWER_TO_IDS):,}")
|
||||
print(f"Типов: {len(ITEMS_BY_TYPE)}")
|
||||
print(f"Редкостей: {len(ITEMS_BY_RARITY)}")
|
||||
print(f"Коллекций/кейсов: {len(COLLECTIONS_INDEX)}")
|
||||
print(f"Источников (can_be_assigned_from): {len(CAN_BE_ASSIGNED_FROM_INDEX)}")
|
||||
|
||||
def warmup_and_debug_data():
|
||||
"""Диагностика данных при старте"""
|
||||
print("\n" + "="*80)
|
||||
print("ПРОГРЕВ ДАННЫХ И ДИАГНОСТИКА")
|
||||
print("="*80)
|
||||
|
||||
rarities = Counter()
|
||||
types_cnt = Counter()
|
||||
knife_count = 0
|
||||
glove_count = 0
|
||||
|
||||
for item in ALL_ITEMS:
|
||||
rarities[item.get("rarity", "Без редкости")] += 1
|
||||
types_cnt[item.get("type", "Без типа")] += 1
|
||||
|
||||
if is_knife(item):
|
||||
knife_count += 1
|
||||
elif is_gloves(item):
|
||||
glove_count += 1
|
||||
|
||||
print(f"Ножей: {knife_count:,}")
|
||||
print(f"Перчаток: {glove_count:,}")
|
||||
print(f"Rare Special Items всего: {knife_count + glove_count:,}")
|
||||
|
||||
print("\nРедкости:")
|
||||
for r, c in sorted(rarities.items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {r:24} → {c:6,}")
|
||||
|
||||
print("\nТипы:")
|
||||
for t, c in types_cnt.most_common(5):
|
||||
print(f" {t:24} → {c:6,}")
|
||||
|
||||
# Проверяем Covert предметы
|
||||
covert_items = [it for it in ALL_ITEMS if it.get("rarity") == "Covert"]
|
||||
covert_knives = [it for it in covert_items if is_knife(it)]
|
||||
covert_skins = [it for it in covert_items if not is_knife(it) and not is_gloves(it)]
|
||||
|
||||
print(f"\nCovert предметы:")
|
||||
print(f" Всего: {len(covert_items):,}")
|
||||
print(f" Ножи: {len(covert_knives):,}")
|
||||
print(f" Обычные скины: {len(covert_skins):,}")
|
||||
|
||||
# Проверяем кейсы
|
||||
print(f"\nКейсов (Case: *): {sum(1 for k in COLLECTIONS_INDEX.keys() if k.startswith('Case:')):,}")
|
||||
|
||||
print("="*80 + "\n")
|
||||
|
||||
# Загружаем и индексируем данные
|
||||
load_and_index_data()
|
||||
warmup_and_debug_data()
|
||||
|
||||
def calculate_average_float(items: List[dict]) -> float:
|
||||
"""Вычисляет средний float из входных предметов"""
|
||||
if not items:
|
||||
return 0.5
|
||||
return sum(it.get("mid_float_used", 0.5) for it in items) / len(items)
|
||||
|
||||
def get_possible_outcomes(items: List[dict]) -> tuple[List[tuple], Dict[str, float]]:
|
||||
"""
|
||||
Определяет возможные исходы контракта и их вероятности.
|
||||
Возвращает список (item_id, probability) и словарь весов источников.
|
||||
"""
|
||||
input_type = items[0].get("type", "Normal")
|
||||
first_item = items[0]
|
||||
|
||||
# Определяем текущий уровень редкости
|
||||
current_level = get_rarity_level(first_item.get("rarity"), first_item)
|
||||
target_level = current_level + 1
|
||||
|
||||
# Собираем источники из входных предметов
|
||||
source_counter = Counter()
|
||||
|
||||
for it in items:
|
||||
# Из can_be_assigned_from
|
||||
assigned = it.get("can_be_assigned_from", [])
|
||||
if isinstance(assigned, list):
|
||||
for source in assigned:
|
||||
source_counter[source] += 1
|
||||
elif isinstance(assigned, str) and assigned:
|
||||
source_counter[assigned] += 1
|
||||
|
||||
# Из collection
|
||||
coll = it.get("collection", "").strip()
|
||||
if coll and coll != "Без коллекции":
|
||||
source_counter[coll] += 1
|
||||
|
||||
# Из crates_names
|
||||
crates = it.get("crates_names", [])
|
||||
if crates and isinstance(crates, list):
|
||||
for crate in crates:
|
||||
source_counter[f"Case: {crate}"] += 1
|
||||
|
||||
total = sum(source_counter.values())
|
||||
if total == 0:
|
||||
return [], {}
|
||||
|
||||
# Веса источников пропорциональны количеству предметов из них
|
||||
source_weights = {s: cnt / total for s, cnt in source_counter.items()}
|
||||
|
||||
# Группируем возможные исходы по базовому имени
|
||||
base_to_ids = defaultdict(list)
|
||||
|
||||
for source_name in source_weights.keys():
|
||||
# Проверяем в COLLECTIONS_INDEX
|
||||
for idx in COLLECTIONS_INDEX.get(source_name, []):
|
||||
cand = get_item(idx)
|
||||
if not cand:
|
||||
continue
|
||||
|
||||
# Проверяем уровень редкости
|
||||
cand_level = get_rarity_level(cand.get("rarity"), cand)
|
||||
if cand_level != target_level:
|
||||
continue
|
||||
|
||||
# Проверяем тип
|
||||
if cand.get("type") != input_type:
|
||||
continue
|
||||
|
||||
base = extract_base_name(cand["market_hash_name"])
|
||||
base_to_ids[base].append(idx)
|
||||
|
||||
# Проверяем в CAN_BE_ASSIGNED_FROM_INDEX
|
||||
for idx in CAN_BE_ASSIGNED_FROM_INDEX.get(source_name, []):
|
||||
cand = get_item(idx)
|
||||
if not cand:
|
||||
continue
|
||||
|
||||
cand_level = get_rarity_level(cand.get("rarity"), cand)
|
||||
if cand_level != target_level:
|
||||
continue
|
||||
|
||||
if cand.get("type") != input_type:
|
||||
continue
|
||||
|
||||
base = extract_base_name(cand["market_hash_name"])
|
||||
base_to_ids[base].append(idx)
|
||||
|
||||
if not base_to_ids:
|
||||
return [], source_weights
|
||||
|
||||
# Равномерный шанс на каждый уникальный базовый скин
|
||||
num_unique = len(base_to_ids)
|
||||
prob_per_base = 1.0 / num_unique
|
||||
|
||||
possible_outcomes = []
|
||||
for base, ids_list in base_to_ids.items():
|
||||
prob_per_id = prob_per_base / len(ids_list)
|
||||
for idx in ids_list:
|
||||
possible_outcomes.append((idx, prob_per_id))
|
||||
|
||||
return possible_outcomes, source_weights
|
||||
|
||||
def simulate_trade_up(items: List[dict]) -> TradeUpSimulationResult:
|
||||
"""Симулирует контракт обмена 10→1"""
|
||||
# Подготовка input_items
|
||||
input_short = []
|
||||
for it in items:
|
||||
if isinstance(it, dict):
|
||||
enriched = it.copy()
|
||||
else:
|
||||
enriched = {}
|
||||
|
||||
if "id" not in enriched:
|
||||
enriched["id"] = -1
|
||||
|
||||
try:
|
||||
short = ItemShort(**enriched)
|
||||
input_short.append(short)
|
||||
except Exception:
|
||||
input_short.append(ItemShort(
|
||||
id=enriched.get("id", -1),
|
||||
market_hash_name=enriched.get("market_hash_name", "???")
|
||||
))
|
||||
|
||||
ok, msg = can_be_upgraded(items)
|
||||
if not ok:
|
||||
return TradeUpSimulationResult(
|
||||
success=False,
|
||||
input_items=input_short,
|
||||
received_item=None,
|
||||
received_float=None,
|
||||
probability=None,
|
||||
message=msg
|
||||
)
|
||||
|
||||
possible_outcomes, source_weights = get_possible_outcomes(items)
|
||||
|
||||
if not possible_outcomes:
|
||||
input_type = items[0].get("type", "Normal")
|
||||
first_item = items[0]
|
||||
current_level = get_rarity_level(first_item.get("rarity"), first_item)
|
||||
target_level = current_level + 1
|
||||
|
||||
# Определяем название целевой редкости
|
||||
rarity_names = {v: k for k, v in RARITY_ORDER.items()}
|
||||
target_rarity_name = rarity_names.get(target_level, f"уровень {target_level}")
|
||||
|
||||
return TradeUpSimulationResult(
|
||||
success=False,
|
||||
input_items=input_short,
|
||||
received_item=None,
|
||||
received_float=None,
|
||||
probability=None,
|
||||
message=f"Нет {input_type} предметов редкостью {target_rarity_name} в выбранных источниках"
|
||||
)
|
||||
|
||||
# Выбираем случайный предмет
|
||||
indices = [o[0] for o in possible_outcomes]
|
||||
weights = [o[1] for o in possible_outcomes]
|
||||
|
||||
chosen_idx = random.choices(indices, weights=weights, k=1)[0]
|
||||
received = get_item(chosen_idx)
|
||||
|
||||
# Вычисляем float (среднее арифметическое входных float)
|
||||
avg_float = calculate_average_float(items)
|
||||
|
||||
# Находим вероятность выпавшего предмета
|
||||
probability = next((p for idx, p in possible_outcomes if idx == chosen_idx), 0)
|
||||
|
||||
return TradeUpSimulationResult(
|
||||
success=True,
|
||||
input_items=input_short,
|
||||
received_item=ItemShort(**{**received, "id": chosen_idx}),
|
||||
received_float=round(avg_float, 4),
|
||||
probability=round(probability * 100, 2),
|
||||
message="Контракт успешен"
|
||||
)
|
||||
|
||||
# ─── Эндпоинты ─────────────────────────────────────────────────────────────
|
||||
@app.get("/items")
|
||||
def get_all_items(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: Optional[str] = Query(None),
|
||||
id: Optional[int] = Query(None),
|
||||
item_type: Optional[str] = Query(None),
|
||||
rarity: Optional[str] = Query(None),
|
||||
min_price: Optional[float] = Query(None, ge=0),
|
||||
max_price: Optional[float] = Query(None, ge=0),
|
||||
wear: Optional[str] = Query(None),
|
||||
):
|
||||
"""Возвращает список предметов с фильтрацией и поиском"""
|
||||
if id is not None:
|
||||
item = get_item(id)
|
||||
if item is None:
|
||||
return []
|
||||
return [ItemShort(**{**item, "id": id})]
|
||||
|
||||
# Базовый набор кандидатов
|
||||
if item_type and item_type.lower() != "all":
|
||||
candidates = ITEMS_BY_TYPE.get(item_type, [])
|
||||
else:
|
||||
candidates = ALL_ITEMS_WITH_ID
|
||||
|
||||
# Фильтр по редкости
|
||||
if rarity:
|
||||
r_norm = rarity.strip()
|
||||
candidates = [it for it in candidates if it.get("rarity", "").strip() == r_norm]
|
||||
|
||||
# Поиск по имени
|
||||
if search:
|
||||
query = search.lower().strip()
|
||||
found_ids = set()
|
||||
|
||||
# Точное совпадение
|
||||
if query in NAME_LOWER_TO_IDS:
|
||||
found_ids.update(NAME_LOWER_TO_IDS[query])
|
||||
|
||||
# Частичное совпадение по словам
|
||||
search_words = query.split()
|
||||
for name_lower, ids in NAME_LOWER_TO_IDS.items():
|
||||
if all(word in name_lower for word in search_words):
|
||||
found_ids.update(ids)
|
||||
elif name_lower.startswith(query):
|
||||
found_ids.update(ids)
|
||||
|
||||
if not found_ids:
|
||||
return []
|
||||
candidates = [it for it in candidates if it["id"] in found_ids]
|
||||
|
||||
# Фильтры по цене и wear
|
||||
filtered = candidates
|
||||
if min_price is not None or max_price is not None:
|
||||
filtered = [
|
||||
it for it in filtered
|
||||
if it.get("price_rub") is not None and
|
||||
(min_price is None or it["price_rub"] >= min_price) and
|
||||
(max_price is None or it["price_rub"] <= max_price)
|
||||
]
|
||||
|
||||
if wear:
|
||||
wear_norm = wear.strip()
|
||||
filtered = [it for it in filtered if it.get("wear", "").strip() == wear_norm]
|
||||
|
||||
# Пагинация
|
||||
sliced = filtered[skip:skip + limit]
|
||||
return [ItemShort(**it) for it in sliced]
|
||||
|
||||
@app.get("/filters")
|
||||
def get_filters():
|
||||
"""Возвращает доступные значения для фильтров"""
|
||||
rarities = set()
|
||||
types = set()
|
||||
wears = set()
|
||||
price_range = {"min": float('inf'), "max": float('-inf')}
|
||||
|
||||
knife_count = 0
|
||||
glove_count = 0
|
||||
normal_count = 0
|
||||
|
||||
for item in ALL_ITEMS:
|
||||
if item.get("rarity"):
|
||||
rarities.add(item["rarity"])
|
||||
if item.get("type"):
|
||||
types.add(item["type"])
|
||||
if item.get("wear"):
|
||||
wears.add(item["wear"])
|
||||
if item.get("price_rub"):
|
||||
price = item["price_rub"]
|
||||
if price < price_range["min"]:
|
||||
price_range["min"] = price
|
||||
if price > price_range["max"]:
|
||||
price_range["max"] = price
|
||||
|
||||
if is_knife(item):
|
||||
knife_count += 1
|
||||
elif is_gloves(item):
|
||||
glove_count += 1
|
||||
else:
|
||||
normal_count += 1
|
||||
|
||||
return {
|
||||
"rarities": sorted(list(rarities)),
|
||||
"types": sorted(list(types)),
|
||||
"wears": sorted(list(wears)),
|
||||
"price_range": {
|
||||
"min_rub": price_range["min"] if price_range["min"] != float('inf') else 0,
|
||||
"max_rub": price_range["max"] if price_range["max"] != float('-inf') else 0
|
||||
},
|
||||
"statistics": {
|
||||
"total_items": len(ALL_ITEMS),
|
||||
"knives": knife_count,
|
||||
"gloves": glove_count,
|
||||
"normal_skins": normal_count,
|
||||
"note": "Ножи и перчатки нельзя использовать в trade-up контрактах"
|
||||
}
|
||||
}
|
||||
|
||||
@app.post("/contract/submit", response_model=TradeUpSimulationResult)
|
||||
def submit_contract(req: ContractSubmitRequest):
|
||||
"""Выполняет симуляцию контракта обмена"""
|
||||
try:
|
||||
selected = []
|
||||
for idx in req.item_ids:
|
||||
item = get_item(idx)
|
||||
if item is None:
|
||||
raise HTTPException(400, detail=f"id {idx} не найден")
|
||||
item_with_id = item.copy()
|
||||
item_with_id["id"] = idx
|
||||
selected.append(item_with_id)
|
||||
return simulate_trade_up(selected)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
@app.get("/collections/")
|
||||
def get_collections_list():
|
||||
"""Возвращает список всех коллекций и источников"""
|
||||
result = []
|
||||
|
||||
all_sources = set(COLLECTIONS_INDEX.keys()) | set(CAN_BE_ASSIGNED_FROM_INDEX.keys())
|
||||
|
||||
for name in sorted(all_sources):
|
||||
ids = set()
|
||||
if name in COLLECTIONS_INDEX:
|
||||
ids.update(COLLECTIONS_INDEX[name])
|
||||
if name in CAN_BE_ASSIGNED_FROM_INDEX:
|
||||
ids.update(CAN_BE_ASSIGNED_FROM_INDEX[name])
|
||||
|
||||
unique_bases = set()
|
||||
for idx in ids:
|
||||
item = get_item(idx)
|
||||
if item:
|
||||
base = extract_base_name(item.get("market_hash_name", ""))
|
||||
if base:
|
||||
unique_bases.add(base)
|
||||
|
||||
result.append({
|
||||
"name": name,
|
||||
"total_items": len(ids),
|
||||
"unique_skins": len(unique_bases),
|
||||
"is_case": name.startswith("Case: ")
|
||||
})
|
||||
|
||||
return {
|
||||
"total_collections": len(result),
|
||||
"collections": result
|
||||
}
|
||||
|
||||
@app.get("/collections/all")
|
||||
def get_collection_names():
|
||||
"""Возвращает подробную информацию о всех коллекциях"""
|
||||
def rarity_key(idx: int) -> int:
|
||||
item = get_item(idx)
|
||||
return get_rarity_level(item.get("rarity"), item) if item else -1
|
||||
|
||||
result = []
|
||||
all_sources = set(COLLECTIONS_INDEX.keys()) | set(CAN_BE_ASSIGNED_FROM_INDEX.keys())
|
||||
|
||||
for source_name in sorted(all_sources):
|
||||
ids = set()
|
||||
if source_name in COLLECTIONS_INDEX:
|
||||
ids.update(COLLECTIONS_INDEX[source_name])
|
||||
if source_name in CAN_BE_ASSIGNED_FROM_INDEX:
|
||||
ids.update(CAN_BE_ASSIGNED_FROM_INDEX[source_name])
|
||||
|
||||
base_names = set()
|
||||
items_info = []
|
||||
|
||||
for i in ids:
|
||||
item = get_item(i)
|
||||
if item:
|
||||
base = extract_base_name(item["market_hash_name"])
|
||||
if base:
|
||||
base_names.add(base)
|
||||
|
||||
items_info.append({
|
||||
"id": i,
|
||||
"name": item.get("market_hash_name", "Unknown"),
|
||||
"rarity": item.get("rarity", "Unknown"),
|
||||
"type": item.get("type", "Normal"),
|
||||
"wear": item.get("wear", "Unknown"),
|
||||
"price_rub": item.get("price_rub"),
|
||||
"price_usd": item.get("price_usd"),
|
||||
"is_craftable": item.get("is_craftable", True)
|
||||
})
|
||||
|
||||
sorted_ids = sorted(ids, key=rarity_key)
|
||||
items_info.sort(key=lambda x: (
|
||||
get_rarity_level(x["rarity"], get_item(x["id"])),
|
||||
x["name"]
|
||||
))
|
||||
|
||||
result.append({
|
||||
"name": source_name,
|
||||
"unique_skin_count": len(base_names),
|
||||
"total_variants": len(ids),
|
||||
"item_ids_by_rarity": sorted_ids,
|
||||
"items": items_info
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@app.get("/collection/{name}", response_model=CollectionDetail)
|
||||
def get_one_collection(name: str):
|
||||
"""Возвращает детальную информацию о конкретной коллекции/источнике"""
|
||||
ids = set()
|
||||
if name in COLLECTIONS_INDEX:
|
||||
ids.update(COLLECTIONS_INDEX[name])
|
||||
if name in CAN_BE_ASSIGNED_FROM_INDEX:
|
||||
ids.update(CAN_BE_ASSIGNED_FROM_INDEX[name])
|
||||
|
||||
if not ids:
|
||||
raise HTTPException(404, detail="Коллекция или источник не найдены")
|
||||
|
||||
groups = defaultdict(list)
|
||||
for idx in ids:
|
||||
item = get_item(idx)
|
||||
if not item:
|
||||
continue
|
||||
base = extract_base_name(item["market_hash_name"])
|
||||
if not base:
|
||||
continue
|
||||
groups[base].append((
|
||||
item.get("mid_float_used", 999.9),
|
||||
idx,
|
||||
item.get("type", "Normal"),
|
||||
item.get("rarity", "Unknown")
|
||||
))
|
||||
|
||||
result_items = []
|
||||
for base_name, variants in sorted(groups.items()):
|
||||
rarity = next((v[3] for v in variants if v[3] != "Unknown"), "Unknown")
|
||||
normal = [v[1] for v in sorted(variants) if v[2] == "Normal"]
|
||||
stattrak = [v[1] for v in sorted(variants) if v[2] == "StatTrak"]
|
||||
souvenir = [v[1] for v in sorted(variants) if v[2] == "Souvenir"]
|
||||
result_items.append(CollectionItemBase(
|
||||
base_name=base_name,
|
||||
rarity=rarity,
|
||||
item_ids_normal=normal,
|
||||
item_ids_stattrak=stattrak,
|
||||
item_ids_souvenir=souvenir,
|
||||
count_variants=len(variants)
|
||||
))
|
||||
|
||||
return CollectionDetail(
|
||||
collection=name,
|
||||
total_unique_skins=len(result_items),
|
||||
items=result_items
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Проверка работоспособности сервера"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"items_loaded": len(ALL_ITEMS),
|
||||
"base_items": len(BASE_ITEMS),
|
||||
"custom_items": len(CUSTOM_ITEMS)
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=13668)
|
||||
+1452
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
[
|
||||
{
|
||||
"market_hash_name": "★ Dildo | Fade (Factory New)",
|
||||
"weapon": "Dildo",
|
||||
"pattern": "Fade",
|
||||
"rarity": "Covert",
|
||||
"collection": "Dildo Collection",
|
||||
"type": "Normal",
|
||||
"wear": "Factory New",
|
||||
"wear_range": [
|
||||
0.0,
|
||||
0.07
|
||||
],
|
||||
"mid_float_used": 0.01,
|
||||
"doppler_phase": null,
|
||||
"price_usd": 1000.0,
|
||||
"price_rub": 100000.0,
|
||||
"price_source": "custom",
|
||||
"image_url": "",
|
||||
"crates_names": [
|
||||
"create: dildo_case"
|
||||
],
|
||||
"float_range": [
|
||||
0.0,
|
||||
0.07
|
||||
],
|
||||
"can_be_assigned_from": [
|
||||
"create: dildo_case"
|
||||
],
|
||||
"is_craftable": true
|
||||
},
|
||||
{
|
||||
"market_hash_name": "★ Dildo | Crimson Web (Minimal Wear)",
|
||||
"weapon": "Dildo",
|
||||
"pattern": "Crimson Web",
|
||||
"rarity": "Covert",
|
||||
"collection": "Dildo Collection",
|
||||
"type": "Normal",
|
||||
"wear": "Minimal Wear",
|
||||
"wear_range": [
|
||||
0.07,
|
||||
0.15
|
||||
],
|
||||
"mid_float_used": 0.1,
|
||||
"doppler_phase": null,
|
||||
"price_usd": 800.0,
|
||||
"price_rub": 80000.0,
|
||||
"price_source": "custom",
|
||||
"image_url": "",
|
||||
"crates_names": [
|
||||
"create: dildo_case"
|
||||
],
|
||||
"float_range": [
|
||||
0.07,
|
||||
0.15
|
||||
],
|
||||
"can_be_assigned_from": [
|
||||
"create: dildo_case"
|
||||
],
|
||||
"is_craftable": true
|
||||
},
|
||||
{
|
||||
"market_hash_name": "Dildo | Blue Gem (Factory New)",
|
||||
"weapon": "Dildo",
|
||||
"pattern": "Blue Gem",
|
||||
"rarity": "Classified",
|
||||
"collection": "Dildo Collection",
|
||||
"type": "Normal",
|
||||
"wear": "Factory New",
|
||||
"wear_range": [
|
||||
0.0,
|
||||
0.07
|
||||
],
|
||||
"mid_float_used": 0.01,
|
||||
"doppler_phase": null,
|
||||
"price_usd": 500.0,
|
||||
"price_rub": 50000.0,
|
||||
"price_source": "custom",
|
||||
"image_url": "",
|
||||
"crates_names": [
|
||||
"create: dildo_case"
|
||||
],
|
||||
"float_range": [
|
||||
0.0,
|
||||
0.07
|
||||
],
|
||||
"can_be_assigned_from": [
|
||||
"create: dildo_case"
|
||||
],
|
||||
"is_craftable": true
|
||||
}
|
||||
]
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
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 json
|
||||
from pathlib import Path
|
||||
|
||||
DATABASE_URL = "sqlite:///./cs2_simulator.db"
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False)
|
||||
hashed_password = Column(String(200), nullable=False)
|
||||
balance = Column(Float, default=0.0)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
is_banned = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login = 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")
|
||||
contracts = relationship("Contract", back_populates="user", cascade="all, delete-orphan")
|
||||
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")
|
||||
|
||||
class UpgradeRPU(Base):
|
||||
__tablename__ = "upgrade_rpu"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=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) # Стоимость потерянных предметов
|
||||
|
||||
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"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
input_item_id = Column(Integer, nullable=False)
|
||||
input_item_name = Column(String(200))
|
||||
input_item_image_url = Column(String(500), default="")
|
||||
target_item_id = Column(Integer, nullable=False)
|
||||
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) # Шанс с учетом РПУ
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="upgrades")
|
||||
|
||||
class UserRPU(Base):
|
||||
__tablename__ = "user_rpu"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=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)
|
||||
restricted_multiplier = Column(Float, default=1.0)
|
||||
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) # Всего открыто кейсов
|
||||
last_adjustment = 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 InventoryItem(Base):
|
||||
__tablename__ = "inventory_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
item_id = Column(Integer, nullable=False) # ID из ALL_ITEMS
|
||||
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_at = Column(DateTime, default=datetime.utcnow)
|
||||
is_equipped = Column(Boolean, default=False)
|
||||
|
||||
user = relationship("User", back_populates="inventory_items")
|
||||
|
||||
class CaseOpening(Base):
|
||||
__tablename__ = "case_openings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
case_name = Column(String(100), nullable=False)
|
||||
item_id = Column(Integer, nullable=False)
|
||||
item_name = Column(String(200), nullable=False)
|
||||
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"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
input_item_ids = Column(Text, nullable=False) # JSON список ID из инвентаря
|
||||
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)
|
||||
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
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
user_achievements = relationship("UserAchievement", back_populates="achievement")
|
||||
|
||||
|
||||
class UserAchievement(Base):
|
||||
__tablename__ = "user_achievements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False)
|
||||
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"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=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
|
||||
message = Column(String(500), nullable=False)
|
||||
data_json = Column(Text, default="{}") # дополнительные данные (item_name, price, etc.)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", backref="activities")
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
+2724
File diff suppressed because it is too large
Load Diff
+492
@@ -0,0 +1,492 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from tqdm import tqdm
|
||||
from collections import defaultdict
|
||||
|
||||
# URLs
|
||||
SKINS_URL = "https://raw.githubusercontent.com/ByMykel/CSGO-API/main/public/api/en/skins.json"
|
||||
BUFF_PRICES_URL = "https://prices.csgotrader.app/latest/buff163.json"
|
||||
|
||||
# Файлы
|
||||
LOG_FILE = "skin_parser_log.txt"
|
||||
OUTPUT_FILE = "full_skins_with_buff_prices.json"
|
||||
MISSING_PRICES_FILE = "missing_prices.txt"
|
||||
|
||||
# Множители относительно Factory New (на основе реальных данных Buff163)
|
||||
WEAR_MULTIPLIERS_FROM_FN = {
|
||||
"Factory New": 1.00,
|
||||
"Minimal Wear": 0.82, # было 0.78, скорректировано по реальным данным
|
||||
"Field-Tested": 0.58, # было 0.52
|
||||
"Well-Worn": 0.42, # было 0.38
|
||||
"Battle-Scarred": 0.35, # было 0.28
|
||||
}
|
||||
|
||||
# Типы относительно Normal (на основе реальных данных Buff163)
|
||||
# Статистика по 1000+ случайных скинов:
|
||||
# StatTrak/Normal: медиана 1.65, среднее 1.72
|
||||
# Souvenir/Normal: медиана 0.72, среднее 0.74
|
||||
TYPE_MULTIPLIERS = {
|
||||
"Normal": 1.00,
|
||||
"StatTrak": 1.68, # было 1.65, небольшое повышение
|
||||
"Souvenir": 0.72, # было 0.68, существенная коррекция
|
||||
}
|
||||
|
||||
# Дополнительные множители для специфичных случаев
|
||||
RARITY_MULTIPLIERS = {
|
||||
"Consumer Grade": 1.00,
|
||||
"Industrial Grade": 1.00,
|
||||
"Mil-Spec": 1.00,
|
||||
"Mil-Spec Grade": 1.00,
|
||||
"Restricted": 1.00,
|
||||
"Classified": 1.00,
|
||||
"Covert": 1.00,
|
||||
"Extraordinary": 1.00,
|
||||
"Contraband": 1.00,
|
||||
}
|
||||
|
||||
# Коррекция для ножей и перчаток (они имеют другие соотношения цен)
|
||||
KNIFE_GLOVE_WEAR_MULTIPLIERS = {
|
||||
"Factory New": 1.00,
|
||||
"Minimal Wear": 0.88, # ножи меньше теряют в цене от износа
|
||||
"Field-Tested": 0.72,
|
||||
"Well-Worn": 0.58,
|
||||
"Battle-Scarred": 0.52,
|
||||
}
|
||||
|
||||
def log_message(message, level="INFO"):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_entry = f"[{timestamp}] [{level}] {message}"
|
||||
print(log_entry)
|
||||
try:
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(log_entry + "\n")
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_usd_to_rub_rate():
|
||||
log_message("Получаю курс USD → RUB...")
|
||||
try:
|
||||
r = requests.get("https://www.cbr-xml-daily.ru/daily_json.js", timeout=10)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
rate = float(data["Valute"]["USD"]["Value"])
|
||||
log_message(f"Курс ЦБ РФ: 1 USD = {rate:.2f} ₽", "INFO")
|
||||
return rate
|
||||
except Exception as e:
|
||||
log_message(f"ЦБ РФ недоступен: {e}", "WARNING")
|
||||
try:
|
||||
r = requests.get("https://api.exchangerate-api.com/v4/latest/USD", timeout=10)
|
||||
r.raise_for_status()
|
||||
rate = r.json()["rates"]["RUB"]
|
||||
log_message(f"Курс exchangerate-api: 1 USD = {rate:.2f} ₽", "INFO")
|
||||
return rate
|
||||
except Exception as e2:
|
||||
log_message(f"Все курсы упали: {e2}", "ERROR")
|
||||
return 95.0
|
||||
|
||||
def load_buff_prices():
|
||||
log_message("Загружаю цены с Buff163...")
|
||||
url = BUFF_PRICES_URL
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
r = requests.get(url, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
log_message(f"Загружено {len(data)} записей цен", "INFO")
|
||||
return data
|
||||
except requests.exceptions.Timeout:
|
||||
log_message(f"Таймаут (попытка {attempt}/3), ждём 10 сек...", "WARNING")
|
||||
time.sleep(10)
|
||||
except Exception as e:
|
||||
log_message(f"Ошибка загрузки Buff (попытка {attempt}): {e}", "ERROR")
|
||||
if attempt == 3:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def fetch_and_prepare_skins():
|
||||
log_message("Загружаю список скинов с GitHub...")
|
||||
try:
|
||||
r = requests.get(SKINS_URL, timeout=15)
|
||||
r.raise_for_status()
|
||||
raw_skins = r.json()
|
||||
log_message(f"Получено {len(raw_skins)} записей", "INFO")
|
||||
except Exception as e:
|
||||
log_message(f"Ошибка загрузки скинов: {e}", "ERROR")
|
||||
sys.exit(1)
|
||||
|
||||
prepared = []
|
||||
skipped = 0
|
||||
|
||||
for raw in raw_skins:
|
||||
try:
|
||||
if raw is None or not isinstance(raw, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
weapon_dict = raw.get("weapon")
|
||||
if not isinstance(weapon_dict, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
pattern_dict = raw.get("pattern")
|
||||
if not isinstance(pattern_dict, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
weapon = weapon_dict.get("name", "")
|
||||
if not weapon:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
pattern = pattern_dict.get("name", "")
|
||||
full_name = raw.get("name", "").strip()
|
||||
if not full_name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
base_name = full_name if full_name.startswith("★ ") else f"{weapon} | {pattern}".strip()
|
||||
if "★" in full_name and not base_name.startswith("★ "):
|
||||
base_name = f"★ {base_name}"
|
||||
|
||||
collections = raw.get("collections", [])
|
||||
collection_name = ""
|
||||
if isinstance(collections, list) and len(collections) > 0:
|
||||
first = collections[0]
|
||||
if isinstance(first, dict):
|
||||
collection_name = first.get("name", "")
|
||||
|
||||
# Определяем, является ли предмет ножом или перчатками
|
||||
is_knife_or_glove = "Knife" in weapon or "Bayonet" in weapon or "Wraps" in weapon or "Gloves" in weapon or "Hand" in weapon
|
||||
|
||||
entry = {
|
||||
"weapon": weapon,
|
||||
"pattern": pattern,
|
||||
"rarity": raw.get("rarity", {}).get("name", ""),
|
||||
"collection": collection_name,
|
||||
"float_range": [raw.get("min_float", 0.0), raw.get("max_float", 1.0)],
|
||||
"stattrak": raw.get("stattrak", False),
|
||||
"souvenir": raw.get("souvenir", False),
|
||||
"crates_names": [c.get("name", "") for c in raw.get("crates", []) if isinstance(c, dict) and c.get("name")],
|
||||
"original_name": full_name,
|
||||
"image_url": raw.get("image", ""),
|
||||
"base_name": base_name,
|
||||
"is_knife_or_glove": is_knife_or_glove # Добавляем флаг
|
||||
}
|
||||
prepared.append(entry)
|
||||
|
||||
except Exception as e:
|
||||
name_for_log = raw.get("name", "без имени") if isinstance(raw, dict) else "неизвестно"
|
||||
log_message(f"Ошибка обработки '{name_for_log}': {e}", "WARNING")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
log_message(f"Подготовлено {len(prepared)} валидных скинов", "INFO")
|
||||
if skipped > 0:
|
||||
log_message(f"Пропущено / обработано с предупреждениями: {skipped}", "INFO")
|
||||
|
||||
return prepared
|
||||
|
||||
def get_wear_multiplier(wear: str, is_knife_or_glove: bool = False) -> float:
|
||||
"""Возвращает множитель износа с учетом типа предмета"""
|
||||
if is_knife_or_glove:
|
||||
return KNIFE_GLOVE_WEAR_MULTIPLIERS.get(wear, 0.5)
|
||||
return WEAR_MULTIPLIERS_FROM_FN.get(wear, 0.5)
|
||||
|
||||
def generate_full_variants(skins_list, buff_prices, usd_rub_rate):
|
||||
doppler_keywords = {"Doppler", "Gamma Doppler", "Emerald", "Ruby", "Sapphire", "Black Pearl"}
|
||||
|
||||
result = []
|
||||
missing_original = []
|
||||
|
||||
wear_conditions = [
|
||||
{"name": "Factory New", "min": 0.00, "max": 0.07},
|
||||
{"name": "Minimal Wear", "min": 0.07, "max": 0.15},
|
||||
{"name": "Field-Tested", "min": 0.15, "max": 0.38},
|
||||
{"name": "Well-Worn", "min": 0.38, "max": 0.45},
|
||||
{"name": "Battle-Scarred", "min": 0.45, "max": 1.00},
|
||||
]
|
||||
|
||||
wear_ranges = {
|
||||
"Factory New": (0.00, 0.07),
|
||||
"Minimal Wear": (0.07, 0.15),
|
||||
"Field-Tested": (0.15, 0.38),
|
||||
"Well-Worn": (0.38, 0.45),
|
||||
"Battle-Scarred": (0.45, 1.00),
|
||||
}
|
||||
|
||||
pbar = tqdm(desc="Генерация + цены Buff")
|
||||
|
||||
for skin in skins_list:
|
||||
base_name = skin["base_name"]
|
||||
skin_min_f = skin["float_range"][0]
|
||||
skin_max_f = skin["float_range"][1]
|
||||
is_knife_or_glove = skin.get("is_knife_or_glove", False)
|
||||
|
||||
is_doppler = any(kw in base_name for kw in doppler_keywords)
|
||||
|
||||
for wear in wear_conditions:
|
||||
actual_min = max(skin_min_f, wear["min"])
|
||||
actual_max = min(skin_max_f, wear["max"])
|
||||
|
||||
if actual_min >= actual_max:
|
||||
continue
|
||||
|
||||
mid_float = (actual_min + actual_max) / 2
|
||||
base_variant = f"{base_name} ({wear['name']})"
|
||||
|
||||
buff_data = buff_prices.get(base_variant, {})
|
||||
price_usd = None
|
||||
price_source = None
|
||||
|
||||
if is_doppler:
|
||||
doppler_dict = buff_data.get("doppler", {}) if isinstance(buff_data.get("doppler"), dict) else {}
|
||||
if doppler_dict:
|
||||
for phase, phase_price in doppler_dict.items():
|
||||
if phase_price is None:
|
||||
continue
|
||||
variant_name = f"{base_variant} ({phase})"
|
||||
entry = {
|
||||
"market_hash_name": variant_name,
|
||||
"weapon": skin["weapon"],
|
||||
"pattern": skin["pattern"],
|
||||
"rarity": skin["rarity"],
|
||||
"collection": skin["collection"],
|
||||
"type": "Normal",
|
||||
"wear": wear["name"],
|
||||
"wear_range": [round(actual_min, 4), round(actual_max, 4)],
|
||||
"mid_float_used": round(mid_float, 4),
|
||||
"doppler_phase": phase,
|
||||
"price_usd": phase_price,
|
||||
"price_rub": round(phase_price * usd_rub_rate, 2) if phase_price is not None and usd_rub_rate else None,
|
||||
"price_source": f"doppler_{phase}",
|
||||
"image_url": skin["image_url"],
|
||||
"crates_names": skin["crates_names"],
|
||||
"is_knife_or_glove": is_knife_or_glove
|
||||
}
|
||||
result.append(entry)
|
||||
pbar.update(1)
|
||||
continue
|
||||
|
||||
price_usd = buff_data.get("starting_at", {}).get("price") or buff_data.get("highest_order", {}).get("price")
|
||||
price_source = "starting_at" if buff_data.get("starting_at") else "highest_order" if price_usd else None
|
||||
|
||||
entry = {
|
||||
"market_hash_name": base_variant,
|
||||
"weapon": skin["weapon"],
|
||||
"pattern": skin["pattern"],
|
||||
"rarity": skin["rarity"],
|
||||
"collection": skin["collection"],
|
||||
"type": "Normal",
|
||||
"wear": wear["name"],
|
||||
"wear_range": [round(actual_min, 4), round(actual_max, 4)],
|
||||
"mid_float_used": round(mid_float, 4),
|
||||
"doppler_phase": None,
|
||||
"price_usd": price_usd,
|
||||
"price_rub": round(price_usd * usd_rub_rate, 2) if price_usd is not None and usd_rub_rate else None,
|
||||
"price_source": price_source,
|
||||
"image_url": skin["image_url"],
|
||||
"crates_names": skin["crates_names"],
|
||||
"float_range": skin["float_range"],
|
||||
"is_knife_or_glove": is_knife_or_glove
|
||||
}
|
||||
result.append(entry)
|
||||
pbar.update(1)
|
||||
|
||||
if skin["stattrak"]:
|
||||
st_name = f"StatTrak™ {base_variant}"
|
||||
st_buff = buff_prices.get(st_name, {})
|
||||
st_price = st_buff.get("starting_at", {}).get("price") or st_buff.get("highest_order", {}).get("price")
|
||||
st_entry = {**entry, "market_hash_name": st_name, "type": "StatTrak", "price_usd": st_price}
|
||||
st_entry["price_rub"] = round(st_price * usd_rub_rate, 2) if st_price is not None and usd_rub_rate else None
|
||||
st_entry["price_source"] = "starting_at" if st_buff.get("starting_at") else "highest_order" if st_price else None
|
||||
result.append(st_entry)
|
||||
pbar.update(1)
|
||||
|
||||
if skin["souvenir"]:
|
||||
souv_name = f"Souvenir {base_variant}"
|
||||
souv_buff = buff_prices.get(souv_name, {})
|
||||
souv_price = souv_buff.get("starting_at", {}).get("price") or souv_buff.get("highest_order", {}).get("price")
|
||||
souv_entry = {**entry, "market_hash_name": souv_name, "type": "Souvenir", "price_usd": souv_price}
|
||||
souv_entry["price_rub"] = round(souv_price * usd_rub_rate, 2) if souv_price is not None and usd_rub_rate else None
|
||||
souv_entry["price_source"] = "starting_at" if souv_buff.get("starting_at") else "highest_order" if souv_price else None
|
||||
result.append(souv_entry)
|
||||
pbar.update(1)
|
||||
|
||||
pbar.close()
|
||||
|
||||
# Пост-обработка — точная оценка с учётом float-range и приоритетов
|
||||
log_message("Заполняю пропущенные цены с учётом float-range и приоритетов...", "INFO")
|
||||
|
||||
price_map = defaultdict(lambda: defaultdict(lambda: defaultdict(float)))
|
||||
|
||||
for item in result:
|
||||
if item.get("price_usd") is None:
|
||||
continue
|
||||
|
||||
base_key = item["market_hash_name"].split(" (")[0].lower().replace("stattrak™ ", "").replace("souvenir ", "").strip()
|
||||
wear = item["wear"]
|
||||
typ = item["type"]
|
||||
price_map[base_key][wear][typ] = item["price_usd"]
|
||||
|
||||
estimated_count = 0
|
||||
|
||||
for item in result:
|
||||
if item.get("price_usd") is not None:
|
||||
continue
|
||||
|
||||
base_key = item["market_hash_name"].split(" (")[0].lower().replace("stattrak™ ", "").replace("souvenir ", "").strip()
|
||||
target_wear = item["wear"]
|
||||
target_type = item["type"]
|
||||
skin_min_f, skin_max_f = item.get("float_range", [0.0, 1.0])
|
||||
is_knife_or_glove = item.get("is_knife_or_glove", False)
|
||||
|
||||
# Проверка допустимости износа
|
||||
wear_min, wear_max = {
|
||||
"Factory New": (0.00, 0.07),
|
||||
"Minimal Wear": (0.07, 0.15),
|
||||
"Field-Tested": (0.15, 0.38),
|
||||
"Well-Worn": (0.38, 0.45),
|
||||
"Battle-Scarred": (0.45, 1.00),
|
||||
}.get(target_wear, (0.0, 1.0))
|
||||
|
||||
if max(skin_min_f, wear_min) >= min(skin_max_f, wear_max):
|
||||
missing_original.append(f"{item['market_hash_name']} [impossible float range]")
|
||||
continue
|
||||
|
||||
# Поиск референсной цены по приоритетам
|
||||
ref_price = None
|
||||
source_note = ""
|
||||
ref_wear = None
|
||||
ref_type = None
|
||||
|
||||
# 1. Тот же wear + тип
|
||||
if target_wear in price_map[base_key] and target_type in price_map[base_key][target_wear]:
|
||||
ref_price = price_map[base_key][target_wear][target_type]
|
||||
source_note = "same wear + type"
|
||||
ref_wear = target_wear
|
||||
ref_type = target_type
|
||||
|
||||
# 2. Тот же wear, любой тип
|
||||
elif target_wear in price_map[base_key]:
|
||||
prices = [(t, p) for t, p in price_map[base_key][target_wear].items() if p > 0]
|
||||
if prices:
|
||||
# Предпочитаем тот же тип или Normal
|
||||
prices.sort(key=lambda x: (0 if x[0] == target_type else 1 if x[0] == "Normal" else 2, -x[1]))
|
||||
ref_type, ref_price = prices[0]
|
||||
source_note = f"same wear, type {ref_type}"
|
||||
ref_wear = target_wear
|
||||
|
||||
# 3. Тот же тип, другой wear
|
||||
else:
|
||||
prices_same_type = []
|
||||
for w, types_dict in price_map[base_key].items():
|
||||
if target_type in types_dict:
|
||||
prices_same_type.append((w, types_dict[target_type]))
|
||||
if prices_same_type:
|
||||
# Выбираем ближайший wear по качеству
|
||||
prices_same_type.sort(key=lambda x: (0 if x[0] == target_wear else 1, -x[1]))
|
||||
ref_wear, ref_price = prices_same_type[0]
|
||||
source_note = f"same type, wear {ref_wear}"
|
||||
ref_type = target_type
|
||||
|
||||
# 4. Любая цена на скине
|
||||
if ref_price is None:
|
||||
all_prices = []
|
||||
for w in price_map[base_key]:
|
||||
for t, p in price_map[base_key][w].items():
|
||||
all_prices.append((w, t, p))
|
||||
if all_prices:
|
||||
# Предпочитаем Normal и ближайший wear
|
||||
all_prices.sort(key=lambda x: (
|
||||
0 if x[1] == "Normal" else 1,
|
||||
0 if x[0] == target_wear else 1,
|
||||
-x[2]
|
||||
))
|
||||
ref_wear, ref_type, ref_price = all_prices[0]
|
||||
source_note = f"any variant (wear {ref_wear}, type {ref_type})"
|
||||
|
||||
if ref_price is None:
|
||||
missing_original.append(item["market_hash_name"])
|
||||
continue
|
||||
|
||||
# Корректировка цены с учетом износа и типа
|
||||
multiplier = 1.0
|
||||
|
||||
# Если референс из другого износа — применяем wear-множитель
|
||||
if ref_wear and ref_wear != target_wear:
|
||||
# Нормализуем к Factory New, затем к целевому wear
|
||||
ref_wear_mult = get_wear_multiplier(ref_wear, is_knife_or_glove)
|
||||
target_wear_mult = get_wear_multiplier(target_wear, is_knife_or_glove)
|
||||
if ref_wear_mult > 0:
|
||||
multiplier *= (target_wear_mult / ref_wear_mult)
|
||||
source_note += f", wear adjusted ({ref_wear}→{target_wear})"
|
||||
|
||||
# Если референс не того же типа
|
||||
if ref_type and ref_type != target_type:
|
||||
ref_type_mult = TYPE_MULTIPLIERS.get(ref_type, 1.0)
|
||||
target_type_mult = TYPE_MULTIPLIERS.get(target_type, 1.0)
|
||||
if ref_type_mult > 0:
|
||||
multiplier *= (target_type_mult / ref_type_mult)
|
||||
source_note += f", type adjusted ({ref_type}→{target_type})"
|
||||
|
||||
estimated = ref_price * multiplier
|
||||
item["price_usd"] = round(estimated, 2)
|
||||
item["price_rub"] = round(estimated * usd_rub_rate, 2) if usd_rub_rate else None
|
||||
item["price_source"] = f"estimated ({source_note})"
|
||||
estimated_count += 1
|
||||
|
||||
log_message(f"Оценено {estimated_count} пропущенных цен", "INFO")
|
||||
|
||||
# Удаляем временное поле is_knife_or_glove из результата
|
||||
for item in result:
|
||||
item.pop("is_knife_or_glove", None)
|
||||
item.pop("float_range", None) # тоже удаляем, не нужно в финальном файле
|
||||
|
||||
return result, missing_original
|
||||
|
||||
def save_missing_prices(missing_list):
|
||||
if not missing_list:
|
||||
return
|
||||
try:
|
||||
with open(MISSING_PRICES_FILE, "w", encoding="utf-8") as f:
|
||||
for item in sorted(set(missing_list)):
|
||||
f.write(f"{item}\n")
|
||||
log_message(f"Сохранено {len(set(missing_list))} полностью отсутствующих / невозможных вариантов в {MISSING_PRICES_FILE}", "INFO")
|
||||
except Exception as e:
|
||||
log_message(f"Ошибка сохранения {MISSING_PRICES_FILE}: {e}", "ERROR")
|
||||
|
||||
def main():
|
||||
log_message("=" * 60, "INFO")
|
||||
log_message("🚀 ЗАПУСК ГЕНЕРАТОРА ЦЕН ДЛЯ СКИНОВ CS2", "INFO")
|
||||
log_message("=" * 60, "INFO")
|
||||
|
||||
usd_rub_rate = get_usd_to_rub_rate()
|
||||
buff_prices = load_buff_prices()
|
||||
skins_list = fetch_and_prepare_skins()
|
||||
|
||||
log_message("Начинаю генерацию вариантов...", "INFO")
|
||||
final_list, missing_original = generate_full_variants(skins_list, buff_prices, usd_rub_rate)
|
||||
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(final_list, f, ensure_ascii=False, indent=2)
|
||||
|
||||
priced_count = sum(1 for item in final_list if item.get("price_rub") is not None)
|
||||
log_message(f"Готово! Сохранено {len(final_list)} вариантов", "INFO")
|
||||
log_message(f"С реальной ценой: {priced_count} ({priced_count / len(final_list) * 100:.1f}%)", "INFO")
|
||||
log_message(f"Оценено: {len(final_list) - priced_count} ({(len(final_list) - priced_count) / len(final_list) * 100:.1f}%)", "INFO")
|
||||
log_message(f"Файл: {OUTPUT_FILE}", "INFO")
|
||||
|
||||
save_missing_prices(missing_original)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
log_message("Прервано пользователем", "WARNING")
|
||||
except Exception as e:
|
||||
log_message(f"Критическая ошибка: {e}", "ERROR")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,63 @@
|
||||
[
|
||||
{
|
||||
"tab": "Популярные",
|
||||
"cases": [
|
||||
"allin1",
|
||||
"Operation Phoenix Weapon Case",
|
||||
"Prisma Case"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Обычные кейсы",
|
||||
"cases": [
|
||||
"CS:GO Weapon Case",
|
||||
"Revolution Case",
|
||||
"Recoil Case"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Новинки",
|
||||
"cases": [
|
||||
"allin1",
|
||||
"Danger Zone Case",
|
||||
"Prisma Case",
|
||||
"Fracture Case",
|
||||
"Prisma 2 Case",
|
||||
"m9orbutterfly1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Премиум кейсы",
|
||||
"cases": [
|
||||
"Operation Bravo Case",
|
||||
"Operation Hydra Case",
|
||||
"Operation Phoenix Weapon Case",
|
||||
"m9orbutterfly1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Elite Коллекция",
|
||||
"cases": [
|
||||
"elite1",
|
||||
"elite2",
|
||||
"elite3",
|
||||
"elite4",
|
||||
"elite5",
|
||||
"elite6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Фарм кейсы",
|
||||
"cases": [
|
||||
"lightingstrikefarm",
|
||||
"dlorfarm",
|
||||
"howlfarm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Слоты",
|
||||
"cases": [
|
||||
"slotcase1"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from database import User, UserRPU
|
||||
|
||||
# Стандартные веса редкостей
|
||||
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
|
||||
}
|
||||
|
||||
# Маппинг редкостей на поля в UserRPU
|
||||
RARITY_TO_FIELD = {
|
||||
"Consumer Grade": "consumer_multiplier",
|
||||
"Industrial Grade": "industrial_multiplier",
|
||||
"Mil-Spec": "mil_spec_multiplier",
|
||||
"Mil-Spec Grade": "mil_spec_multiplier",
|
||||
"Restricted": "restricted_multiplier",
|
||||
"Classified": "classified_multiplier",
|
||||
"Covert": "covert_multiplier",
|
||||
"Rare Special Item": "rare_special_multiplier",
|
||||
"Extraordinary": "rare_special_multiplier"
|
||||
}
|
||||
|
||||
|
||||
def get_user_rpu(db: Session, user_id: int) -> UserRPU:
|
||||
"""Получает или создает настройки РПУ для пользователя"""
|
||||
rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first()
|
||||
if not rpu:
|
||||
rpu = UserRPU(user_id=user_id)
|
||||
db.add(rpu)
|
||||
db.commit()
|
||||
db.refresh(rpu)
|
||||
return rpu
|
||||
|
||||
|
||||
def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
|
||||
"""
|
||||
Получает веса редкостей с учетом РПУ пользователя.
|
||||
НИЗКИЙ % РПУ = ПОВЫШЕННЫЕ шансы на редкое (везёт)
|
||||
ВЫСОКИЙ % РПУ = ПОНИЖЕННЫЕ шансы на редкое (не везёт)
|
||||
"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
|
||||
# Вычисляем общий уровень РПУ (0-100%)
|
||||
rpu_level = calculate_rpu_level(rpu)
|
||||
|
||||
# Переворачиваем: низкий РПУ = высокий множитель для редких
|
||||
# При 0% РПУ множитель для редких = 2.0 (очень везёт)
|
||||
# При 50% РПУ множитель = 1.0 (обычно)
|
||||
# При 100% РПУ множитель = 0.1 (совсем не везёт)
|
||||
|
||||
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 = 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
|
||||
else:
|
||||
# Не везёт: понижаем шансы на редкое
|
||||
luck_factor = 1.0 - ((rpu_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
|
||||
|
||||
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)
|
||||
|
||||
if not rpu.auto_adjust:
|
||||
return
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return
|
||||
|
||||
# Анализируем статистику
|
||||
if rpu.total_opened > 0:
|
||||
avg_spent_per_case = rpu.total_spent / rpu.total_opened
|
||||
|
||||
# Если пользователь много тратит и мало получает - снижаем РПУ (даём везение)
|
||||
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)
|
||||
|
||||
# Если пользователь часто открывает кейсы - немного снижаем РПУ
|
||||
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)))
|
||||
|
||||
rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier))
|
||||
rpu.last_adjustment = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1):
|
||||
"""Обновляет статистику РПУ после открытия кейсов"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
rpu.total_spent += spent
|
||||
rpu.total_opened += opened
|
||||
db.commit()
|
||||
|
||||
# Авто-подкрутка если включена
|
||||
if rpu.auto_adjust:
|
||||
auto_adjust_rpu(db, user_id)
|
||||
+373840
File diff suppressed because it is too large
Load Diff
+430000
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
// ─── Shared CS2 Simulator Utilities ─────────────────────────────────────────
|
||||
|
||||
// Logout
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||||
} catch (e) {}
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// Refresh balance display
|
||||
async function refreshBalance() {
|
||||
try {
|
||||
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)} ₽`;
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Rarity colors
|
||||
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';
|
||||
}
|
||||
|
||||
// Format number with spaces
|
||||
function formatNumber(n) {
|
||||
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
}
|
||||
|
||||
// Debounce
|
||||
function debounce(fn, ms = 300) {
|
||||
let timer;
|
||||
return function (...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Show notification
|
||||
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);
|
||||
}
|
||||
|
||||
// Inject notification styles once
|
||||
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);
|
||||
}
|
||||
|
||||
// Show achievement popup (global function used by templates)
|
||||
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 = reward ? `+${reward} ₽` : '';
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// ─── Safe Mode (Recording Mode for Streamers/Bloggers) ─────────────────────
|
||||
// Replaces gambling terms with YouTube-safe synonyms
|
||||
|
||||
const SafeMode = (() => {
|
||||
const STORAGE_KEY = 'safe_mode';
|
||||
|
||||
const DICT = [
|
||||
// Currency
|
||||
[/₽(?=\s*\d)/g, 'тугриков'],
|
||||
[/(\d+)\s*₽/g, (_, n) => {
|
||||
const num = parseInt(n);
|
||||
const last = num % 10;
|
||||
const lastTwo = num % 100;
|
||||
if (lastTwo >= 11 && lastTwo <= 19) return n + ' тугриков';
|
||||
if (last === 1) return n + ' тугрик';
|
||||
if (last >= 2 && last <= 4) return n + ' тугрика';
|
||||
return n + ' тугриков';
|
||||
}],
|
||||
[/₽(?!\d)/g, 'тугриков'],
|
||||
|
||||
// Cases
|
||||
[/\b(открыть|открывай|открыл)\s+кейс/gi, (m) => m.replace(/кейс/i, 'бокс')],
|
||||
[/\bкейс[а-я]*\b/gi, (m) => {
|
||||
const lower = m.toLowerCase();
|
||||
if (lower === 'кейс') return 'бокс';
|
||||
if (lower === 'кейса') return 'бокса';
|
||||
if (lower === 'кейсу') return 'боксу';
|
||||
if (lower === 'кейсом') return 'боксом';
|
||||
if (lower === 'кейсе') return 'боксе';
|
||||
if (lower === 'кейсы') return 'боксы';
|
||||
if (lower === 'кейсов') return 'боксов';
|
||||
if (lower === 'кейсам') return 'боксам';
|
||||
if (lower === 'кейсами') return 'боксами';
|
||||
if (lower === 'кейсах') return 'боксах';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Case (English)
|
||||
[/\bcase\b/gi, 'box'],
|
||||
|
||||
// Skins
|
||||
[/\bскин[а-я]*\b/gi, (m) => {
|
||||
const lower = m.toLowerCase();
|
||||
if (lower === 'скин') return 'облик';
|
||||
if (lower === 'скина') return 'облика';
|
||||
if (lower === 'скину') return 'облику';
|
||||
if (lower === 'скином') return 'обликом';
|
||||
if (lower === 'скине') return 'облике';
|
||||
if (lower === 'скины' || lower === 'скин') return 'облики';
|
||||
if (lower === 'скинов') return 'обликов';
|
||||
if (lower === 'скинам') return 'обликам';
|
||||
if (lower === 'скинами') return 'обликами';
|
||||
if (lower === 'скинах') return 'обликах';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Contract
|
||||
[/\bконтракт[а-я]*\b/gi, (m) => {
|
||||
const lower = m.toLowerCase();
|
||||
if (lower === 'контракт') return 'сделка';
|
||||
if (lower === 'контракта') return 'сделки';
|
||||
if (lower === 'контракту') return 'сделке';
|
||||
if (lower === 'контрактом') return 'сделкой';
|
||||
if (lower === 'контракте') return 'сделке';
|
||||
if (lower === 'контракты' || lower === 'контракт') return 'сделки';
|
||||
if (lower === 'контрактов') return 'сделок';
|
||||
if (lower === 'контрактам') return 'сделкам';
|
||||
if (lower === 'контрактами') return 'сделками';
|
||||
if (lower === 'контрактах') return 'сделках';
|
||||
if (lower === 'контрактная' || lower === 'контрактный' || lower === 'контрактное') return 'договорная';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Upgrade
|
||||
[/\bапгрейд[а-я]*\b/gi, (m) => {
|
||||
if (m.toLowerCase() === 'апгрейд') return 'улучшение';
|
||||
if (m.toLowerCase() === 'апгрейда') return 'улучшения';
|
||||
if (m.toLowerCase() === 'апгрейду') return 'улучшению';
|
||||
if (m.toLowerCase() === 'апгрейдом') return 'улучшением';
|
||||
if (m.toLowerCase() === 'апгрейде') return 'улучшении';
|
||||
if (m.toLowerCase() === 'апгрейды') return 'улучшения';
|
||||
if (m.toLowerCase() === 'апгрейдов') return 'улучшений';
|
||||
return m;
|
||||
}],
|
||||
[/\bupgrade\b/gi, 'upgrade'],
|
||||
|
||||
// Float
|
||||
[/\bfloat\b/gi, 'состояние'],
|
||||
|
||||
// Rarity
|
||||
[/\bредкост[ьи][а-я]*\b/gi, (m) => {
|
||||
const lower = m.toLowerCase();
|
||||
if (lower === 'редкость') return 'класс';
|
||||
if (lower === 'редкости') return 'классы';
|
||||
if (lower === 'редкостью') return 'классом';
|
||||
if (lower === 'редкостей') return 'классов';
|
||||
if (lower === 'редкостям') return 'классам';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Chance
|
||||
[/\bшанс[а-я]*\b/gi, (m) => {
|
||||
const lower = m.toLowerCase();
|
||||
if (lower === 'шанс') return 'вероятность';
|
||||
if (lower === 'шанса') return 'вероятности';
|
||||
if (lower === 'шансу') return 'вероятности';
|
||||
if (lower === 'шансом') return 'вероятностью';
|
||||
if (lower === 'шансе') return 'вероятности';
|
||||
if (lower === 'шансы') return 'вероятности';
|
||||
if (lower === 'шансов') return 'вероятностей';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Bet
|
||||
[/\bставк[а-я]*\b/gi, (m) => {
|
||||
const lower = m.toLowerCase();
|
||||
if (lower === 'ставка') return 'вход';
|
||||
if (lower === 'ставки') return 'входы';
|
||||
if (lower === 'ставку') return 'вход';
|
||||
if (lower === 'ставкой') return 'входом';
|
||||
if (lower === 'ставок') return 'входов';
|
||||
if (lower === 'ставкам') return 'входам';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Win / Lose
|
||||
[/\bвыигрыш[а-я]*\b/gi, (m) => {
|
||||
if (m.toLowerCase() === 'выигрыш' || m.toLowerCase() === 'выигрыша') return 'приз';
|
||||
if (m.toLowerCase() === 'выигрышу') return 'призу';
|
||||
if (m.toLowerCase() === 'выигрышем') return 'призом';
|
||||
if (m.toLowerCase() === 'выигрыше') return 'призе';
|
||||
return m;
|
||||
}],
|
||||
[/\b(выиграл|выиграла|выиграли)\b/gi, (m) => {
|
||||
if (m.toLowerCase() === 'выиграл' || m.toLowerCase() === 'выиграла') return 'получил(a)';
|
||||
if (m.toLowerCase() === 'выиграли') return 'получили';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Lose
|
||||
[/\bпроиграл[а-я]*\b/gi, 'не получилось'],
|
||||
|
||||
// Crash game
|
||||
[/\b(краш|crash)\b/gi, 'риск-игра'],
|
||||
|
||||
// Open case
|
||||
[/\bоткры[тв][а-я]+\s+кейс/gi, (m) => m.replace(/кейс/i, 'бокс')],
|
||||
|
||||
// Wear
|
||||
[/\bизнос\b/gi, 'состояние'],
|
||||
|
||||
// Probability display
|
||||
[/\bвероятность\b/gi, 'шанс'],
|
||||
|
||||
// Quick mode tooltips
|
||||
[/\bбыстрый режим\b/gi, 'турбо-режим'],
|
||||
|
||||
// Balance
|
||||
[/\bбаланс\b/gi, 'счёт'],
|
||||
|
||||
// Sell
|
||||
[/\bпрода[жв][а-я]*\b/gi, (m) => {
|
||||
const lower = m.toLowerCase();
|
||||
if (lower === 'продажа' || lower === 'продать') return 'обменять';
|
||||
if (lower === 'продал') return 'обменял';
|
||||
if (lower === 'продайте') return 'обменяйте';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Inventory
|
||||
[/\bинвентар[ьи][а-я]*\b/gi, (m) => {
|
||||
const lower = m.toLowerCase();
|
||||
if (lower === 'инвентарь') return 'коллекция';
|
||||
if (lower === 'инвентаря') return 'коллекции';
|
||||
if (lower === 'инвентаре') return 'коллекции';
|
||||
if (lower === 'инвентарём') return 'коллекцией';
|
||||
return m;
|
||||
}],
|
||||
|
||||
// Trade up
|
||||
[/\btrade.?up\b/gi, 'treyd-ap'],
|
||||
|
||||
// Premium
|
||||
[/\bпремиум\b/gi, 'эксклюзив'],
|
||||
|
||||
// Rare names in English
|
||||
[/\bcovert\b/gi, 'covert'],
|
||||
[/\bclassified\b/gi, 'classified'],
|
||||
[/\bconsumers?\b/gi, 'consumer'],
|
||||
[/\bindustrials?\b/gi, 'industrial'],
|
||||
[/\bmil-spec\b/gi, 'mil-spec'],
|
||||
[/\brestricted\b/gi, 'restricted'],
|
||||
];
|
||||
|
||||
let observer = null;
|
||||
let active = false;
|
||||
|
||||
function isActive() {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
}
|
||||
|
||||
function setActive(val) {
|
||||
localStorage.setItem(STORAGE_KEY, val ? 'true' : 'false');
|
||||
active = val;
|
||||
}
|
||||
|
||||
function replaceText(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
let text = node.textContent;
|
||||
let changed = false;
|
||||
for (const [pattern, replacement] of DICT) {
|
||||
const newText = text.replace(pattern, replacement);
|
||||
if (newText !== text) {
|
||||
text = newText;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
node.textContent = text;
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Don't process script, style, input, textarea, canvas elements
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (tag === 'script' || tag === 'style' || tag === 'input' ||
|
||||
tag === 'textarea' || tag === 'canvas' || tag === 'svg' ||
|
||||
tag === 'code' || tag === 'pre') {
|
||||
return;
|
||||
}
|
||||
// Process title and alt attributes
|
||||
if (node.hasAttribute('title')) {
|
||||
let t = node.getAttribute('title');
|
||||
for (const [pattern, replacement] of DICT) {
|
||||
t = t.replace(pattern, replacement);
|
||||
}
|
||||
node.setAttribute('title', t);
|
||||
}
|
||||
if (node.hasAttribute('alt')) {
|
||||
let a = node.getAttribute('alt');
|
||||
for (const [pattern, replacement] of DICT) {
|
||||
a = a.replace(pattern, replacement);
|
||||
}
|
||||
node.setAttribute('alt', a);
|
||||
}
|
||||
// Process children
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
replaceText(node.childNodes[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
replaceText(document.body);
|
||||
// Update document title
|
||||
if (document.title) {
|
||||
let t = document.title;
|
||||
for (const [pattern, replacement] of DICT) {
|
||||
t = t.replace(pattern, replacement);
|
||||
}
|
||||
document.title = t;
|
||||
}
|
||||
// Update safe mode button appearance
|
||||
const btn = document.getElementById('safeModeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = active ? '🛡️' : '🎙️';
|
||||
btn.title = active ? 'Режим записи активен' : 'Режим записи';
|
||||
btn.style.borderColor = active ? 'var(--success-color)' : '';
|
||||
}
|
||||
}
|
||||
|
||||
function startObserver() {
|
||||
if (observer) observer.disconnect();
|
||||
observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
replaceText(node);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
replaceText(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
active = !isActive();
|
||||
setActive(active);
|
||||
if (active) {
|
||||
apply();
|
||||
startObserver();
|
||||
} else {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
// Reload to restore original text
|
||||
location.reload();
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function init() {
|
||||
active = isActive();
|
||||
if (active) {
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
apply();
|
||||
startObserver();
|
||||
});
|
||||
} else {
|
||||
apply();
|
||||
startObserver();
|
||||
}
|
||||
}
|
||||
// Set initial button state
|
||||
const btn = document.getElementById('safeModeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = active ? '🛡️' : '🎙️';
|
||||
btn.title = active ? 'Режим записи активен' : 'Режим записи';
|
||||
if (active) btn.style.borderColor = 'var(--success-color)';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
toggle,
|
||||
isActive
|
||||
};
|
||||
})();
|
||||
|
||||
// Global toggle function for onclick in navbars
|
||||
function toggleSafeMode() {
|
||||
SafeMode.toggle();
|
||||
}
|
||||
|
||||
// Auto-init
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => SafeMode.init());
|
||||
} else {
|
||||
SafeMode.init();
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Sound effects system using Web Audio API - no external files needed
|
||||
const SoundManager = {
|
||||
_ctx: null,
|
||||
_enabled: true,
|
||||
_volume: 0.3,
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
_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);
|
||||
},
|
||||
|
||||
_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();
|
||||
},
|
||||
|
||||
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._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);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
slotSpin() {
|
||||
// Ratcheting sound for slot reels
|
||||
for (let i = 0; i < 8; i++) {
|
||||
setTimeout(() => {
|
||||
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);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
wheelSpin() {
|
||||
// Ticking as wheel rotates
|
||||
this._play(200, 0.03, 'square', 0.1);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
crashBet() {
|
||||
this._play(300, 0.1, 'square', 0.15);
|
||||
},
|
||||
|
||||
crashTick() {
|
||||
this._play(500 + Math.random() * 500, 0.03, 'sine', 0.08);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-init
|
||||
document.addEventListener('DOMContentLoaded', () => SoundManager.init());
|
||||
@@ -0,0 +1,93 @@
|
||||
// WebSocket client for real-time features
|
||||
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}//${window.location.host}/ws`;
|
||||
|
||||
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) {
|
||||
// ignore non-JSON messages
|
||||
}
|
||||
};
|
||||
|
||||
// Keepalive ping every 30s
|
||||
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));
|
||||
// Also emit all for catch-all listeners
|
||||
(this._listeners['*'] || []).forEach(cb => cb(event, data));
|
||||
},
|
||||
|
||||
isConnected() {
|
||||
return this._connected;
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-connect on page load
|
||||
document.addEventListener('DOMContentLoaded', () => WS.connect());
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1,292 @@
|
||||
<style>
|
||||
.activity-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 260px;
|
||||
background: var(--bg-card);
|
||||
border-right: 1px solid var(--border-color);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-top: 60px;
|
||||
transform: translateX(0);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
}
|
||||
.activity-sidebar.closed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.activity-sidebar-header {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-sidebar-header h3 {
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.live-dot {
|
||||
width: 5px; height: 5px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: pulse 1.5s ease infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100%{opacity:1}50%{opacity:0.3}
|
||||
}
|
||||
.activity-sidebar-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.activity-sidebar-list::-webkit-scrollbar { width: 3px; }
|
||||
.activity-sidebar-list::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
|
||||
.activity-sidebar-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.15rem;
|
||||
transition: background 0.15s;
|
||||
animation: fadeInItem 0.3s ease;
|
||||
}
|
||||
.activity-sidebar-item:hover { background: var(--bg-dark); }
|
||||
.activity-sidebar-item .asi-icon {
|
||||
font-size: 0.85rem;
|
||||
width: 24px; height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-sidebar-item .asi-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.activity-sidebar-item .asi-content .asi-username {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
.activity-sidebar-item .asi-content .asi-time {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.activity-sidebar-item .asi-item-preview {
|
||||
width: 28px; height: 21px;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-sidebar-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.activity-sidebar-close:hover { opacity: 1; }
|
||||
|
||||
/* Кнопка открытия ленты (слева, по центру) */
|
||||
.activity-toggle-btn {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: none;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 0.6rem 0.3rem;
|
||||
cursor: pointer;
|
||||
z-index: 49;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 0.05em;
|
||||
display: none;
|
||||
user-select: none;
|
||||
}
|
||||
.activity-toggle-btn:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.activity-toggle-btn.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
@keyframes fadeInItem {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.has-activity-sidebar {
|
||||
padding-left: 260px;
|
||||
transition: padding-left 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
}
|
||||
.has-activity-sidebar.sidebar-closed {
|
||||
padding-left: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.activity-sidebar { display: none; }
|
||||
.activity-toggle-btn { display: none !important; }
|
||||
.has-activity-sidebar { padding-left: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<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">
|
||||
<div style="text-align:center;padding:2rem;color:var(--text-secondary);font-size:0.85rem;">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="activity-toggle-btn" id="activityToggleBtn" onclick="toggleActivitySidebar()" title="Показать ленту">
|
||||
📰 Лента
|
||||
</button>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const list = document.getElementById('activitySidebarList');
|
||||
const sidebar = document.getElementById('activitySidebar');
|
||||
document.body.classList.add('has-activity-sidebar');
|
||||
|
||||
// Восстанавливаем состояние из localStorage
|
||||
if (localStorage.getItem('sidebar_closed') === 'true') {
|
||||
sidebar.classList.add('closed');
|
||||
document.body.classList.add('sidebar-closed');
|
||||
document.getElementById('activityToggleBtn').classList.add('visible');
|
||||
}
|
||||
|
||||
WS.on('online_count', (data) => {
|
||||
document.getElementById('onlineCount').textContent = data.online || 0;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch('/web/api/activity?limit=10');
|
||||
const data = await res.json();
|
||||
if (data.success && data.activities) {
|
||||
renderActivities(data.activities);
|
||||
}
|
||||
} catch (e) {
|
||||
if (list) list.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary);">Не удалось загрузить</div>';
|
||||
}
|
||||
|
||||
WS.on('activity', (data) => {
|
||||
const act = data.activity;
|
||||
if (!act || !list) return;
|
||||
const empty = list.querySelector('div[style*="text-align:center"]');
|
||||
if (empty) empty.remove();
|
||||
const item = createActivityItem(act);
|
||||
list.insertBefore(item, list.firstChild);
|
||||
if (act.activity_type === 'achievement') SoundManager.achievement();
|
||||
while (list.children.length > 15) {
|
||||
list.removeChild(list.lastChild);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const RARITY_COLORS = {
|
||||
'consumer-grade': '#b0b0b0', 'industrial-grade': '#5e98d9',
|
||||
'mil-spec': '#4b69ff', 'mil-spec-grade': '#4b69ff',
|
||||
'restricted': '#8847ff', 'classified': '#d32ce6',
|
||||
'covert': '#eb4b4b', 'rare-special-item': '#ffd700',
|
||||
'rare-special': '#ffd700', 'extraordinary': '#ffd700',
|
||||
'contraband': '#ffaa00'
|
||||
};
|
||||
|
||||
function createActivityItem(act) {
|
||||
const iconMap = {
|
||||
'case_open': '📦', 'contract': '🔄', 'upgrade': '🆙',
|
||||
'achievement': '🏆', 'crash': '💥'
|
||||
};
|
||||
const item = document.createElement('div');
|
||||
item.className = 'activity-sidebar-item';
|
||||
item.style.cursor = 'pointer';
|
||||
|
||||
const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, '');
|
||||
const rarityColor = RARITY_COLORS[raritySlug] || '';
|
||||
if (rarityColor) {
|
||||
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => window.location.href = `/profiles/${act.user_id}`);
|
||||
|
||||
const icon = iconMap[act.activity_type] || '📌';
|
||||
const imgHtml = (act.data && act.data.item_image)
|
||||
? `<img class="asi-item-preview" src="${act.data.item_image}" onerror="this.style.display='none'">`
|
||||
: '';
|
||||
const timeStr = act.created_at
|
||||
? new Date(act.created_at).toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'})
|
||||
: '';
|
||||
item.innerHTML = `
|
||||
<div class="asi-icon">${icon}</div>
|
||||
<div class="asi-content">
|
||||
<span class="asi-username">${escapeHtml(act.username)}</span>
|
||||
<span>${escapeHtml(act.message)}</span>
|
||||
<div class="asi-time">${timeStr}</div>
|
||||
</div>
|
||||
${imgHtml}
|
||||
`;
|
||||
return item;
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const v = parseInt(hex.replace('#', ''), 16);
|
||||
return (v >> 16) + ',' + ((v >> 8) & 255) + ',' + (v & 255);
|
||||
}
|
||||
|
||||
function renderActivities(activities) {
|
||||
const list = document.getElementById('activitySidebarList');
|
||||
if (!list) return;
|
||||
if (!activities || activities.length === 0) {
|
||||
list.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary);font-size:0.85rem;">Пока нет активностей</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
activities.forEach(a => list.appendChild(createActivityItem(a)));
|
||||
}
|
||||
|
||||
function toggleActivitySidebar() {
|
||||
const s = document.getElementById('activitySidebar');
|
||||
const btn = document.getElementById('activityToggleBtn');
|
||||
if (!s) return;
|
||||
const isOpen = !s.classList.contains('closed');
|
||||
s.classList.toggle('closed', isOpen);
|
||||
document.body.classList.toggle('sidebar-closed', isOpen);
|
||||
btn.classList.toggle('visible', isOpen);
|
||||
localStorage.setItem('sidebar_closed', isOpen ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = text || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<!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>
|
||||
.achievements-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.achievements-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.achievements-stats .stat {
|
||||
text-align: center;
|
||||
}
|
||||
.achievements-stats .stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.achievements-stats .stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.achievements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.achievement-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.achievement-card.unlocked {
|
||||
border-color: var(--success-color);
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.05), transparent);
|
||||
}
|
||||
.achievement-card.unlocked::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle, rgba(34, 197, 94, 0.08), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.achievement-card.locked {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.achievement-icon {
|
||||
font-size: 2rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.achievement-card.unlocked .achievement-icon {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
.achievement-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.achievement-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.achievement-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.achievement-reward {
|
||||
font-size: 0.8rem;
|
||||
color: var(--success-color);
|
||||
}
|
||||
.achievement-progress {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.progress-fill.complete {
|
||||
background: var(--success-color);
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.category-filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.category-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.category-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.category-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
.new-achievement-popup {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #1a3a1a, #0d260d);
|
||||
border: 1px solid var(--success-color);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.5rem;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
animation: slideInRight 0.5s ease;
|
||||
max-width: 350px;
|
||||
}
|
||||
.new-achievement-popup.hiding {
|
||||
animation: slideOutRight 0.5s ease forwards;
|
||||
}
|
||||
@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; }
|
||||
}
|
||||
.new-achievement-popup .popup-title {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.new-achievement-popup .popup-achievement {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.new-achievement-popup .popup-reward {
|
||||
color: var(--success-color);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/achievements" class="nav-link active">🏆 Достижения</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container" style="padding: 2rem 1rem;">
|
||||
<div class="achievements-header">
|
||||
<div>
|
||||
<h1>🏆 Достижения</h1>
|
||||
<p class="page-subtitle">Выполняй задания и получай награды</p>
|
||||
</div>
|
||||
<div class="achievements-stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ unlocked }}</div>
|
||||
<div class="stat-label">Открыто</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ total }}</div>
|
||||
<div class="stat-label">Всего</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ "%.0f"|format(unlocked/total*100 if total > 0 else 0) }}%</div>
|
||||
<div class="stat-label">Прогресс</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-filter" id="categoryFilter">
|
||||
<button class="category-btn active" data-cat="all">Все</button>
|
||||
<button class="category-btn" data-cat="cases">📦 Кейсы</button>
|
||||
<button class="category-btn" data-cat="contracts">🔄 Контракты</button>
|
||||
<button class="category-btn" data-cat="upgrade">🆙 Апгрейд</button>
|
||||
<button class="category-btn" data-cat="crash">💥 Crash</button>
|
||||
<button class="category-btn" data-cat="general">🎯 Общие</button>
|
||||
</div>
|
||||
|
||||
<div class="achievements-grid" id="achievementsGrid">
|
||||
{% for ach in achievements %}
|
||||
<div class="achievement-card {{ 'unlocked' if ach.unlocked else 'locked' }}"
|
||||
data-category="{{ ach.category }}">
|
||||
<div class="achievement-icon">{{ ach.icon }}</div>
|
||||
<div class="achievement-info">
|
||||
<div class="achievement-title">{{ ach.title }}</div>
|
||||
<div class="achievement-desc">{{ ach.description }}</div>
|
||||
{% if ach.reward_amount > 0 %}
|
||||
<div class="achievement-reward">+{{ "%.0f"|format(ach.reward_amount) }} ₽</div>
|
||||
{% endif %}
|
||||
<div class="achievement-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill {{ 'complete' if ach.unlocked else '' }}"
|
||||
style="width: {{ (ach.progress / ach.requirement_value * 100)|round }}%"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
{% if ach.unlocked %}
|
||||
✅ Получено
|
||||
{% else %}
|
||||
{{ ach.progress }} / {{ ach.requirement_value }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="achievementPopup" class="new-achievement-popup" style="display:none;">
|
||||
<div class="popup-title">🏆 Новое достижение!</div>
|
||||
<div class="popup-achievement" id="popupTitle"></div>
|
||||
<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/safemode.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Категории
|
||||
document.querySelectorAll('.category-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const cat = btn.dataset.cat;
|
||||
document.querySelectorAll('.achievement-card').forEach(card => {
|
||||
card.style.display = (cat === 'all' || card.dataset.category === cat) ? 'flex' : 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function showAchievementPopup(title, reward) {
|
||||
const popup = document.getElementById('achievementPopup');
|
||||
document.getElementById('popupTitle').textContent = title;
|
||||
document.getElementById('popupReward').textContent = reward ? `+${reward} ₽` : '';
|
||||
popup.style.display = 'block';
|
||||
SoundManager.achievement();
|
||||
setTimeout(() => {
|
||||
popup.classList.add('hiding');
|
||||
setTimeout(() => {
|
||||
popup.style.display = 'none';
|
||||
popup.classList.remove('hiding');
|
||||
}, 500);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
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>
|
||||
{% include '_activity_sidebar.html' %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,245 @@
|
||||
<!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>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link active">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link active">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<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/safemode.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Подключаемся к WS и слушаем новые активности
|
||||
WS.on('activity', (data) => {
|
||||
const act = data.activity;
|
||||
if (!act) return;
|
||||
|
||||
const feed = document.getElementById('activityFeed');
|
||||
|
||||
// Убираем empty state если есть
|
||||
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);
|
||||
|
||||
// Ограничиваем до 100 элементов
|
||||
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>
|
||||
@@ -0,0 +1,361 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 15px; }
|
||||
body {
|
||||
background: #0b0c10;
|
||||
color: #e8e8e8;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ─── Sidebar ─── */
|
||||
.admin-sidebar {
|
||||
width: 240px;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #111216 0%, #0d0e12 100%);
|
||||
border-right: 1px solid rgba(255,255,255,0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-sidebar .sb-brand {
|
||||
padding: 1.25rem 1.25rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.admin-sidebar .sb-brand span { color: #f59e0b; }
|
||||
.admin-sidebar .sb-nav {
|
||||
flex: 1; padding: 0.75rem 0;
|
||||
display: flex; flex-direction: column; gap: 0.15rem;
|
||||
}
|
||||
.admin-sidebar .sb-nav a {
|
||||
display: flex; align-items: center; gap: 0.65rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255,255,255,0.55);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
border-left: 2px solid transparent;
|
||||
font-weight: 450;
|
||||
}
|
||||
.admin-sidebar .sb-nav a:hover {
|
||||
color: rgba(255,255,255,0.85);
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.admin-sidebar .sb-nav a.active {
|
||||
color: #f59e0b;
|
||||
background: rgba(245,158,11,0.06);
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
.admin-sidebar .sb-nav a .sb-icon { font-size: 1rem; width: 20px; text-align: center; }
|
||||
.admin-sidebar .sb-footer {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.admin-sidebar .sb-footer a {
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-decoration: none;
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.admin-sidebar .sb-footer a:hover { color: #f59e0b; }
|
||||
|
||||
/* ─── Main area ─── */
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.admin-topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: rgba(17,18,22,0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.admin-topbar .at-title {
|
||||
font-size: 1rem; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.admin-topbar .at-title .at-sub {
|
||||
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 .at-back {
|
||||
color: rgba(255,255,255,0.5); text-decoration: none;
|
||||
font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.admin-topbar .at-actions .at-back:hover { color: #f59e0b; }
|
||||
.admin-topbar .at-actions .at-user {
|
||||
color: rgba(255,255,255,0.5); font-size: 0.8rem;
|
||||
display: flex; align-items: center; gap: 0.3rem;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ─── Cards ─── */
|
||||
.a-card {
|
||||
background: rgba(17,18,22,0.6);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.a-card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem; font-weight: 600;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
/* ─── Stats grid ─── */
|
||||
.a-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.a-stat {
|
||||
background: rgba(17,18,22,0.6);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
display: flex; flex-direction: column; gap: 0.25rem;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.a-stat .as-label {
|
||||
font-size: 0.7rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255,255,255,0.35);
|
||||
}
|
||||
.a-stat .as-value {
|
||||
font-size: 1.8rem; font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.a-stat .as-bar {
|
||||
position: absolute; bottom: 0; left: 0; height: 2px;
|
||||
border-radius: 0 2px 0 0;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.a-stat .as-sub {
|
||||
font-size: 0.75rem; color: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
/* ─── Tables ─── */
|
||||
.a-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.a-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.a-table th {
|
||||
text-align: left;
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255,255,255,0.35);
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.a-table td {
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.a-table tr:last-child td { border-bottom: none; }
|
||||
.a-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
.a-table .td-actions {
|
||||
display: flex; gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.a-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 7px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: rgba(255,255,255,0.7);
|
||||
text-decoration: none;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.a-btn:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.15); color: #fff; }
|
||||
.a-btn-primary {
|
||||
background: rgba(245,158,11,0.12);
|
||||
border-color: rgba(245,158,11,0.2);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.a-btn-primary:hover { background: rgba(245,158,11,0.2); border-color: #f59e0b; }
|
||||
.a-btn-danger {
|
||||
background: rgba(239,68,68,0.1);
|
||||
border-color: rgba(239,68,68,0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
.a-btn-danger:hover { background: rgba(239,68,68,0.2); border-color: #ef4444; }
|
||||
.a-btn-success {
|
||||
background: rgba(34,197,94,0.1);
|
||||
border-color: rgba(34,197,94,0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
.a-btn-success:hover { background: rgba(34,197,94,0.2); border-color: #22c55e; }
|
||||
.a-btn-sm { padding: 0.3rem 0.55rem; font-size: 0.7rem; }
|
||||
.a-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ─── Form inputs ─── */
|
||||
.a-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 7px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #e8e8e8;
|
||||
font-size: 0.82rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.a-input:focus { border-color: rgba(245,158,11,0.4); }
|
||||
.a-input-sm { padding: 0.35rem 0.5rem; font-size: 0.78rem; }
|
||||
.a-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
/* ─── Badge ─── */
|
||||
.a-badge {
|
||||
display: inline-flex;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.a-badge-admin { background: rgba(245,158,11,0.12); color: #f59e0b; }
|
||||
.a-badge-user { background: rgba(59,130,246,0.12); color: #60a5fa; }
|
||||
.a-badge-banned { background: rgba(239,68,68,0.12); color: #ef4444; }
|
||||
|
||||
/* ─── Modal ─── */
|
||||
.a-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
|
||||
z-index: 100; display: none; align-items: center; justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.a-overlay.open { display: flex; }
|
||||
.a-modal {
|
||||
background: #16171d;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
max-width: 460px; width: 100%;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ─── Grid helpers ─── */
|
||||
.a-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.a-row > * { flex: 1; min-width: 0; }
|
||||
.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; }
|
||||
|
||||
/* ─── Empty state ─── */
|
||||
.a-empty {
|
||||
text-align: center; padding: 3rem 1rem;
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="admin-sidebar">
|
||||
<div class="sb-brand">
|
||||
<span style="font-size:1.2rem">⚡</span>
|
||||
<span>CS2 <span>Admin</span></span>
|
||||
</div>
|
||||
<nav class="sb-nav">
|
||||
<a href="/admin" class="{% block nav_dashboard %}{% endblock %}">
|
||||
<span class="sb-icon">📊</span> <span>Дашборд</span>
|
||||
</a>
|
||||
<a href="/admin/users" class="{% block nav_users %}{% endblock %}">
|
||||
<span class="sb-icon">👥</span> <span>Пользователи</span>
|
||||
</a>
|
||||
<a href="/admin/cases" class="{% block nav_cases %}{% endblock %}">
|
||||
<span class="sb-icon">📦</span> <span>Кейсы</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sb-footer">
|
||||
<a href="/">← <span>На сайт</span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-main">
|
||||
<div class="admin-topbar">
|
||||
<div class="at-title">
|
||||
{% block page_title %}Панель управления{% endblock %}
|
||||
</div>
|
||||
<div class="at-actions">
|
||||
{% block top_actions %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block modals %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,361 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Кейсы — Админ-панель{% endblock %}
|
||||
{% block nav_cases %}active{% endblock %}
|
||||
{% block page_title %}📦 Управление кейсами{% endblock %}
|
||||
{% block top_actions %}
|
||||
<button class="a-btn a-btn-primary" onclick="openCreateCaseModal()">➕ Создать кейс</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">📂 Секции главной страницы</div>
|
||||
<div id="mainpageSections">
|
||||
{% for section in mainpage %}
|
||||
<div class="a-card" style="margin-bottom:0.75rem;padding:1rem" data-tab="{{ section.tab }}">
|
||||
<div class="a-flex" style="justify-content:space-between">
|
||||
<strong style="font-size:0.9rem">{{ section.tab }}</strong>
|
||||
<div class="td-actions">
|
||||
<button class="a-btn a-btn-sm" onclick="openAddCaseModal('{{ section.tab }}')">➕ Добавить кейс</button>
|
||||
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteSection('{{ section.tab }}')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-flex" style="flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem">
|
||||
{% for case_name in section.cases %}
|
||||
<span style="background:rgba(255,255,255,0.04);border-radius:6px;padding:0.2rem 0.5rem;font-size:0.78rem;display:flex;align-items:center;gap:0.3rem">
|
||||
🎲 {{ case_name }}
|
||||
<span onclick="removeCaseFromSection('{{ section.tab }}','{{ case_name }}')" style="cursor:pointer;color:#ef4444;font-size:0.7rem">✕</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="a-btn a-mt" onclick="openAddSectionModal()">➕ Добавить секцию</button>
|
||||
</div>
|
||||
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">🎲 Все кейсы</div>
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>Название</th><th>Цена</th><th>Тип</th><th>Предметов</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in cases %}
|
||||
<tr>
|
||||
<td><strong>{{ c.name }}</strong></td>
|
||||
<td>{{ c.price_open }} ₽</td>
|
||||
<td style="font-size:0.75rem">{{ c.display_name|default('') }}</td>
|
||||
<td>{{ c.items_count }}</td>
|
||||
<td class="td-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>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
<!-- Create/Edit Case Modal -->
|
||||
<div class="a-overlay" id="caseModal">
|
||||
<div class="a-modal" style="max-width:600px">
|
||||
<div class="a-modal-title" id="caseModalTitle">🎲 Создать кейс</div>
|
||||
<input type="hidden" id="editCaseName">
|
||||
<div class="a-row a-gap-sm">
|
||||
<div class="a-mb" style="flex:1">
|
||||
<label class="a-label">Название кейса (ID)</label>
|
||||
<input type="text" id="caseNameInput" class="a-input" placeholder="my_case">
|
||||
</div>
|
||||
<div class="a-mb" style="flex:1">
|
||||
<label class="a-label">Отображаемое название</label>
|
||||
<input type="text" id="caseDisplayName" class="a-input" placeholder="Мой кейс">
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-row a-gap-sm">
|
||||
<div class="a-mb" style="flex:1">
|
||||
<label class="a-label">Цена открытия</label>
|
||||
<input type="number" id="casePrice" class="a-input" value="250">
|
||||
</div>
|
||||
<div class="a-mb" style="flex:1">
|
||||
<label class="a-label">Ссылка на изображение</label>
|
||||
<input type="text" id="caseImage" class="a-input" placeholder="/static/cases/default.png">
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-mb">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<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">
|
||||
<div class="a-modal-title">➕ Добавить кейс в секцию</div>
|
||||
<p style="font-size:0.82rem;color:rgba(255,255,255,0.5);margin-bottom:0.75rem">Секция: <strong id="addCaseSectionName"></strong></p>
|
||||
<div class="a-mb">
|
||||
<label class="a-label">Выберите кейс</label>
|
||||
<select id="addCaseSelect" class="a-input"></select>
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('addCaseModal')">Отмена</button>
|
||||
<button class="a-btn a-btn-primary" onclick="confirmAddCase()">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add section modal -->
|
||||
<div class="a-overlay" id="addSectionModal">
|
||||
<div class="a-modal" style="max-width:400px">
|
||||
<div class="a-modal-title">➕ Новая секция</div>
|
||||
<div class="a-mb">
|
||||
<label class="a-label">Название вкладки</label>
|
||||
<input type="text" id="newSectionName" class="a-input" placeholder="Новая коллекция">
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('addSectionModal')">Отмена</button>
|
||||
<button class="a-btn a-btn-primary" onclick="confirmAddSection()">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% 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 .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; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentCaseItems = [];
|
||||
let editingCaseName = 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 = [];
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const r = await fetch('/admin/api/cases/list');
|
||||
const d = await r.json();
|
||||
allCasesList = d.cases || [];
|
||||
});
|
||||
|
||||
// ─── Create / Edit Case ──────────────────────────────────────────────────────
|
||||
function openCreateCaseModal() {
|
||||
editingCaseName = null;
|
||||
document.getElementById('caseModalTitle').textContent = '🎲 Создать кейс';
|
||||
document.getElementById('saveCaseBtn').textContent = '💾 Создать';
|
||||
document.getElementById('editCaseName').value = '';
|
||||
document.getElementById('caseNameInput').value = '';
|
||||
document.getElementById('caseDisplayName').value = '';
|
||||
document.getElementById('casePrice').value = '250';
|
||||
document.getElementById('caseImage').value = '';
|
||||
document.getElementById('caseDescription').value = '';
|
||||
currentCaseItems = [];
|
||||
renderCaseItems();
|
||||
openModal('caseModal');
|
||||
}
|
||||
|
||||
function openEditCaseModal(caseName) {
|
||||
editingCaseName = caseName;
|
||||
document.getElementById('caseModalTitle').textContent = '✏️ Редактировать кейс';
|
||||
document.getElementById('saveCaseBtn').textContent = '💾 Сохранить';
|
||||
document.getElementById('editCaseName').value = caseName;
|
||||
const cfg = {{ cases_config|tojson }};
|
||||
const c = cfg[caseName] || {};
|
||||
document.getElementById('caseNameInput').value = caseName;
|
||||
document.getElementById('caseDisplayName').value = c.display_name || '';
|
||||
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');
|
||||
renderCaseItems();
|
||||
openModal('caseModal');
|
||||
}
|
||||
|
||||
function renderCaseItems() {
|
||||
const div = document.getElementById('caseItemsList');
|
||||
div.innerHTML = currentCaseItems.map((item, i) => `
|
||||
<div class="ci-item">
|
||||
<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>
|
||||
<button class="a-btn a-btn-danger a-btn-sm" onclick="removeCaseItem(${i})">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function removeCaseItem(idx) {
|
||||
currentCaseItems.splice(idx, 1);
|
||||
renderCaseItems();
|
||||
}
|
||||
|
||||
function addCaseItem(item) {
|
||||
if (currentCaseItems.some(i => (i.market_hash_name || i.name) === (item.market_hash_name || item.name))) return;
|
||||
currentCaseItems.push(item);
|
||||
renderCaseItems();
|
||||
}
|
||||
|
||||
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,'"')})">
|
||||
<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);
|
||||
}
|
||||
document.getElementById('caseItemSearchResults').innerHTML = html;
|
||||
}
|
||||
|
||||
async function saveCase() {
|
||||
const name = document.getElementById('caseNameInput').value.trim();
|
||||
if (!name) { alert('Введите название кейса'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('case_name', name);
|
||||
fd.append('display_name', document.getElementById('caseDisplayName').value);
|
||||
fd.append('price_open', document.getElementById('casePrice').value);
|
||||
fd.append('image_url', document.getElementById('caseImage').value);
|
||||
fd.append('description', document.getElementById('caseDescription').value);
|
||||
fd.append('items_json', JSON.stringify(currentCaseItems));
|
||||
|
||||
const isEdit = editingCaseName !== null && editingCaseName !== name;
|
||||
const url = isEdit
|
||||
? `/admin/api/cases/update/${encodeURIComponent(editingCaseName)}`
|
||||
: '/admin/api/cases/create';
|
||||
|
||||
const r = await fetch(url, { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) { window.location.reload(); }
|
||||
else alert(d.error || 'Ошибка');
|
||||
}
|
||||
|
||||
async function deleteCase(name) {
|
||||
if (!confirm(`Удалить кейс "${name}"?`)) return;
|
||||
const fd = new FormData(); fd.append('case_name', name);
|
||||
const r = await fetch(`/admin/api/cases/delete/${encodeURIComponent(name)}`, { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
}
|
||||
|
||||
// ─── 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,'"')})">
|
||||
<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 = '';
|
||||
openModal('addSectionModal');
|
||||
}
|
||||
async function confirmAddSection() {
|
||||
const name = document.getElementById('newSectionName').value.trim();
|
||||
if (!name) return;
|
||||
const fd = new FormData(); fd.append('tab', name);
|
||||
const r = await fetch('/admin/api/mainpage/section/add', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
}
|
||||
async function deleteSection(tab) {
|
||||
if (!confirm(`Удалить секцию "${tab}"?`)) return;
|
||||
const fd = new FormData(); fd.append('tab', tab);
|
||||
const r = await fetch('/admin/api/mainpage/section/delete', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
}
|
||||
|
||||
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.dataset.tab = tab;
|
||||
openModal('addCaseModal');
|
||||
}
|
||||
async function confirmAddCase() {
|
||||
const tab = document.getElementById('addCaseSelect').dataset.tab;
|
||||
const caseName = document.getElementById('addCaseSelect').value;
|
||||
const fd = new FormData();
|
||||
fd.append('tab', tab);
|
||||
fd.append('case_name', caseName);
|
||||
const r = await fetch('/admin/api/mainpage/section/add-case', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
}
|
||||
async function removeCaseFromSection(tab, caseName) {
|
||||
if (!confirm(`Убрать "${caseName}" из "${tab}"?`)) return;
|
||||
const fd = new FormData();
|
||||
fd.append('tab', tab);
|
||||
fd.append('case_name', caseName);
|
||||
const r = await fetch('/admin/api/mainpage/section/remove-case', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Дашборд — Админ-панель{% endblock %}
|
||||
{% block nav_dashboard %}active{% endblock %}
|
||||
{% block page_title %}📊 Дашборд{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-stats">
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Пользователей</div>
|
||||
<div class="as-value">{{ total_users }}</div>
|
||||
<div class="as-bar" style="width:60%;background:#f59e0b"></div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Открыто кейсов</div>
|
||||
<div class="as-value">{{ total_openings }}</div>
|
||||
<div class="as-bar" style="width:45%;background:#60a5fa"></div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Проведено контрактов</div>
|
||||
<div class="as-value">{{ total_contracts }}</div>
|
||||
<div class="as-bar" style="width:30%;background:#a78bfa"></div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Предметов в БД</div>
|
||||
<div class="as-value">{{ total_items }}</div>
|
||||
<div class="as-bar" style="width:75%;background:#f472b6"></div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Общий баланс</div>
|
||||
<div class="as-value">{{ "%.0f"|format(total_balance) }} ₽</div>
|
||||
<div class="as-bar" style="width:40%;background:#34d399"></div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Средний РПУ</div>
|
||||
<div class="as-value">{{ avg_rpu }}%</div>
|
||||
<div class="as-bar" style="width:{{ avg_rpu }}%;background:#fbbf24"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a-row a-mb">
|
||||
<div class="a-card" style="flex:1">
|
||||
<div class="a-card-header">🎲 RPU Overview</div>
|
||||
{% if rpu_count > 0 %}
|
||||
<div class="a-row a-gap-sm a-mb" style="text-align:center;justify-content:space-around">
|
||||
<div><div class="as-value" style="font-size:1.2rem">{{ rpu_count }}</div><div class="as-label" style="font-size:0.7rem">Всего записей</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem">{{ "%.1f"|format(avg_rpu) }}%</div><div class="as-label" style="font-size:0.7rem">Средний уровень</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem" style="color:#22c55e">{{ lucky_count }}</div><div class="as-label" style="font-size:0.7rem">🍀 Везучих</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem" style="color:#f59e0b">{{ normal_count }}</div><div class="as-label" style="font-size:0.7rem">📊 Обычных</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem" style="color:#ef4444">{{ unlucky_count }}</div><div class="as-label" style="font-size:0.7rem">🌧️ Невезучих</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem">{{ "%.0f"|format(total_spent) }} ₽</div><div class="as-label" style="font-size:0.7rem">Потрачено всего</div></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="a-empty"><div class="a-empty-icon">📭</div>Нет данных RPU</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,294 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{{ target_user.username }} — Админ-панель{% endblock %}
|
||||
{% block nav_users %}active{% endblock %}
|
||||
{% block page_title %}
|
||||
👤 {{ target_user.username }}
|
||||
{% if target_user.is_admin %}<span class="a-badge a-badge-admin" style="margin-left:0.5rem">Админ</span>{% endif %}
|
||||
{% if target_user.is_banned %}<span class="a-badge a-badge-banned" style="margin-left:0.5rem">Забанен</span>{% endif %}
|
||||
{% endblock %}
|
||||
{% block top_actions %}
|
||||
<a href="/admin/users" class="at-back">← Назад</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-stats" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Баланс</div>
|
||||
<div class="as-value">{{ "%.0f"|format(target_user.balance) }} ₽</div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Предметов</div>
|
||||
<div class="as-value">{{ inventory|length }}</div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Открытий кейсов</div>
|
||||
<div class="as-value">{{ total_openings }}</div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Контрактов</div>
|
||||
<div class="as-value">{{ total_contracts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPU -->
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">🎲 РПУ (Режим Персонального Угнетения)</div>
|
||||
<div class="a-row a-gap-sm a-mb" id="rpuStats" style="justify-content:space-around;text-align:center">
|
||||
<div><div class="as-value" style="font-size:1.4rem" id="rpuLevel">--</div><div class="as-label" style="font-size:0.7rem">Уровень</div></div>
|
||||
<div><div class="as-value" style="font-size:1.4rem" id="rpuLuck">--</div><div class="as-label" style="font-size:0.7rem">Множитель</div></div>
|
||||
<div><div class="as-value" style="font-size:1.4rem" id="rpuSpent">--</div><div class="as-label" style="font-size:0.7rem">Потрачено</div></div>
|
||||
<div><div class="as-value" style="font-size:1.4rem" id="rpuOpened">--</div><div class="as-label" style="font-size:0.7rem">Открыто</div></div>
|
||||
<div><div class="as-value" style="font-size:1.4rem" id="rpuAutoStatus">--</div><div class="as-label" style="font-size:0.7rem">Авто</div></div>
|
||||
</div>
|
||||
<div class="a-flex" style="flex-wrap:wrap">
|
||||
<button class="a-btn a-btn-primary" onclick="openRPUModal()">⚙️ Настроить</button>
|
||||
<button class="a-btn" style="border-color:#10b981;color:#10b981" onclick="setRPUPreset('lucky')">🍀 Везучий</button>
|
||||
<button class="a-btn" style="border-color:#f59e0b;color:#f59e0b" onclick="setRPUPreset('normal')">📊 Обычный</button>
|
||||
<button class="a-btn" style="border-color:#ef4444;color:#ef4444" onclick="setRPUPreset('unlucky')">🌧️ Невезучий</button>
|
||||
<button class="a-btn a-btn-danger" onclick="setRPUPreset('very_unlucky')">💀 Проклятый</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">🔧 Действия</div>
|
||||
<div class="a-flex" style="flex-wrap:wrap">
|
||||
<button class="a-btn a-btn-primary" onclick="openBalanceModal()">💰 Баланс</button>
|
||||
<button class="a-btn" onclick="openGiveItemModal()">🎁 Выдать предмет</button>
|
||||
{% if target_user.id != user.id %}
|
||||
<button class="a-btn" onclick="toggleAdmin()">{{ "👑 Снять админа" if target_user.is_admin else "👑 Сделать админом" }}</button>
|
||||
{% endif %}
|
||||
<button class="a-btn {{ 'a-btn-danger' if not target_user.is_banned else 'a-btn-success' }}" onclick="toggleBan()">
|
||||
{{ "🔨 Забанить" if not target_user.is_banned else "✅ Разбанить" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Инвентарь -->
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">🎒 Инвентарь (последние 50)</div>
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>ID</th><th>Название</th><th>Редкость</th><th>Float</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in inventory %}
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.market_hash_name[:35] }}{{'…' if item.market_hash_name|length > 35}}</td>
|
||||
<td>{{ item.rarity }}</td>
|
||||
<td>{{ "%.4f"|format(item.float_value) }}</td>
|
||||
<td class="td-actions">
|
||||
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteItem({{ item.id }})">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
<!-- Баланс -->
|
||||
<div class="a-overlay" id="balanceModal">
|
||||
<div class="a-modal">
|
||||
<div class="a-modal-title">💰 Изменить баланс</div>
|
||||
<p style="font-size:0.85rem;color:rgba(255,255,255,0.5);margin-bottom:1rem">
|
||||
Текущий: <strong style="color:#f59e0b">{{ "%.2f"|format(target_user.balance) }} ₽</strong>
|
||||
</p>
|
||||
<div class="a-mb">
|
||||
<label class="a-label">Сумма</label>
|
||||
<input type="number" id="balanceAmount" class="a-input" min="0" step="100" value="1000">
|
||||
</div>
|
||||
<div class="a-mb">
|
||||
<label class="a-label">Операция</label>
|
||||
<select id="balanceOperation" class="a-input">
|
||||
<option value="add">➕ Добавить</option>
|
||||
<option value="remove">➖ Снять</option>
|
||||
<option value="set">🎯 Установить</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('balanceModal')">Отмена</button>
|
||||
<button class="a-btn a-btn-primary" onclick="updateBalance()">Применить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Выдать предмет -->
|
||||
<div class="a-overlay" id="giveItemModal">
|
||||
<div class="a-modal" style="max-width:560px">
|
||||
<div class="a-modal-title">🎁 Выдать предмет</div>
|
||||
<div class="a-mb">
|
||||
<label class="a-label">Поиск</label>
|
||||
<input type="text" id="itemSearchInput" class="a-input" placeholder="Введите название..." oninput="searchItems()">
|
||||
</div>
|
||||
<div id="itemSearchResults" style="max-height:300px;overflow-y:auto"></div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('giveItemModal')">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPU -->
|
||||
<div class="a-overlay" id="rpuModal">
|
||||
<div class="a-modal" style="max-width:500px">
|
||||
<div class="a-modal-title">⚙️ Настройка РПУ</div>
|
||||
<div id="rpuSliders" class="a-mb"></div>
|
||||
<div class="a-mb">
|
||||
<label class="a-label" style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="rpuAutoAdjust"> 🔄 Автоматическая подкрутка
|
||||
</label>
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<button class="a-btn" onclick="closeModal('rpuModal')">Отмена</button>
|
||||
<button class="a-btn a-btn-primary" onclick="saveRPU()">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.slider-group { margin-bottom: 0.75rem; }
|
||||
.slider-group label { display:flex; justify-content:space-between; font-size:0.78rem; color:rgba(255,255,255,0.6); margin-bottom:0.2rem; }
|
||||
.slider-group input[type=range] { width:100%; accent-color:#f59e0b; }
|
||||
#itemSearchResults > div {
|
||||
display:flex; align-items:center; gap:0.75rem;
|
||||
padding:0.5rem; border-bottom:1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
#itemSearchResults > div img { width:40px; height:32px; object-fit:contain; }
|
||||
#itemSearchResults > div .ir-info { flex:1; }
|
||||
#itemSearchResults > div .ir-info .ir-name { font-size:0.82rem; }
|
||||
#itemSearchResults > div .ir-info .ir-meta { font-size:0.7rem; color:rgba(255,255,255,0.35); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const userId = {{ target_user.id }};
|
||||
let currentRPU = null;
|
||||
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||
|
||||
// ====== BALANCE ======
|
||||
function openBalanceModal() { openModal('balanceModal'); }
|
||||
async function updateBalance() {
|
||||
const fd = new FormData();
|
||||
fd.append('amount', document.getElementById('balanceAmount').value);
|
||||
fd.append('operation', document.getElementById('balanceOperation').value);
|
||||
const res = await fetch(`/admin/api/user/${userId}/balance`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); window.location.reload(); }
|
||||
else alert(d.error);
|
||||
}
|
||||
|
||||
// ====== GIVE ITEM ======
|
||||
function openGiveItemModal() { openModal('giveItemModal'); searchItems(); }
|
||||
async function searchItems() {
|
||||
const q = document.getElementById('itemSearchInput').value;
|
||||
const res = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}`);
|
||||
const items = await res.json();
|
||||
document.getElementById('itemSearchResults').innerHTML = items.map(item => `
|
||||
<div>
|
||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="ir-info">
|
||||
<div class="ir-name">${item.name}</div>
|
||||
<div class="ir-meta">${item.rarity} • ${item.price_rub || 0} ₽</div>
|
||||
</div>
|
||||
<button class="a-btn a-btn-primary a-btn-sm" onclick="giveItem(${item.id})">Выдать</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
async function giveItem(itemId) {
|
||||
const fd = new FormData(); fd.append('item_id', itemId);
|
||||
const res = await fetch(`/admin/api/user/${userId}/give-item`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); closeModal('giveItemModal'); window.location.reload(); }
|
||||
else alert(d.error);
|
||||
}
|
||||
|
||||
// ====== DELETE ITEM ======
|
||||
async function deleteItem(invId) {
|
||||
if (!confirm('Удалить предмет?')) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/delete-item/${invId}`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
}
|
||||
|
||||
// ====== TOGGLE ADMIN / BAN ======
|
||||
async function toggleAdmin() {
|
||||
if (!confirm('Изменить статус админа?')) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/toggle-admin`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload(); else alert(d.error);
|
||||
}
|
||||
async function toggleBan() {
|
||||
if (!confirm('{{ "Забанить" if not target_user.is_banned else "Разбанить" }}?')) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/toggle-ban`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload(); else alert(d.error);
|
||||
}
|
||||
|
||||
// ====== RPU ======
|
||||
async function loadRPU() {
|
||||
try {
|
||||
const res = await fetch(`/admin/api/user/${userId}/rpu`);
|
||||
const d = await res.json();
|
||||
currentRPU = d;
|
||||
const lv = Math.round(d.rpu_level);
|
||||
document.getElementById('rpuLevel').textContent = lv + '%';
|
||||
document.getElementById('rpuLevel').style.color = lv < 40 ? '#22c55e' : lv > 60 ? '#ef4444' : '#f59e0b';
|
||||
document.getElementById('rpuLuck').textContent = d.luck_multiplier.toFixed(2) + 'x';
|
||||
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 ? '✅' : '❌';
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
function openRPUModal() {
|
||||
const sliders = document.getElementById('rpuSliders');
|
||||
const rarities = [
|
||||
['consumer','Consumer Grade'],['industrial','Industrial Grade'],
|
||||
['mil_spec','Mil-Spec'],['restricted','Restricted'],
|
||||
['classified','Classified'],['covert','Covert'],['rare_special','Rare Special']
|
||||
];
|
||||
let html = '';
|
||||
rarities.forEach(([k,l]) => {
|
||||
const v = currentRPU?.multipliers[k] || 1.0;
|
||||
html += `<div class="slider-group"><label><span>${l}</span><span id="rp_${k}_v">${v.toFixed(2)}x</span></label>
|
||||
<input type="range" id="rp_${k}" min="0.1" max="3.0" step="0.05" value="${v}"
|
||||
oninput="document.getElementById('rp_${k}_v').textContent=this.value+'x'"></div>`;
|
||||
});
|
||||
const lv = currentRPU?.luck_multiplier || 1.0;
|
||||
html += `<div class="slider-group"><label><span>🍀 Общая удача</span><span id="rp_luck_v">${lv.toFixed(2)}x</span></label>
|
||||
<input type="range" id="rp_luck" min="0.1" max="3.0" step="0.05" value="${lv}"
|
||||
oninput="document.getElementById('rp_luck_v').textContent=this.value+'x'"></div>`;
|
||||
sliders.innerHTML = html;
|
||||
document.getElementById('rpuAutoAdjust').checked = currentRPU?.auto_adjust || false;
|
||||
openModal('rpuModal');
|
||||
}
|
||||
async function saveRPU() {
|
||||
const fd = new FormData();
|
||||
['consumer','industrial','mil_spec','restricted','classified','covert','rare_special'].forEach(k => fd.append(k, document.getElementById('rp_'+k).value));
|
||||
fd.append('luck', document.getElementById('rp_luck').value);
|
||||
fd.append('auto_adjust', document.getElementById('rpuAutoAdjust').checked);
|
||||
const res = await fetch(`/admin/api/user/${userId}/rpu/update`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); closeModal('rpuModal'); loadRPU(); }
|
||||
else alert(d.error);
|
||||
}
|
||||
async function setRPUPreset(preset) {
|
||||
const names = { lucky:'🍀 Везучий', normal:'📊 Обычный', unlucky:'🌧️ Невезучий', very_unlucky:'💀 Проклятый' };
|
||||
if (!confirm('Применить "'+names[preset]+'"?')) return;
|
||||
const fd = new FormData(); fd.append('preset', preset);
|
||||
const res = await fetch(`/admin/api/user/${userId}/rpu/preset`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); loadRPU(); } else alert(d.error);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadRPU);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Пользователи — Админ-панель{% endblock %}
|
||||
{% 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 '' }}">
|
||||
<button class="a-btn a-btn-primary" onclick="searchUsers()">🔍 Найти</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if users %}
|
||||
<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>Достижений</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.id }}</td>
|
||||
<td>
|
||||
<a href="/admin/user/{{ u.id }}" style="color:#f59e0b;text-decoration:none;font-weight:500">
|
||||
{{ u.username }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ "%.0f"|format(u.balance) }} ₽</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>{{ u.case_openings|default(0) }}</td>
|
||||
<td>{{ u.contracts|default(0) }}</td>
|
||||
<td>{{ u.achievements|default(0) }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/admin/user/{{ u.id }}" class="a-btn a-btn-sm">✏️</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="a-empty">
|
||||
<div class="a-empty-icon">📭</div>
|
||||
<h3>Пользователи не найдены</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function searchUsers() {
|
||||
const q = document.getElementById('userSearch').value.trim();
|
||||
window.location.href = '/admin/users' + (q ? '?search=' + encodeURIComponent(q) : '');
|
||||
}
|
||||
document.getElementById('userSearch').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') searchUsers();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
<!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>
|
||||
.cases-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.cases-container h1 {
|
||||
font-size: 1.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.section-tab {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
display: inline-block;
|
||||
font-family: 'Russo One', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.section-cases {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.case-card-new {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.case-card-new:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.case-image {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.case-image img {
|
||||
max-width: 80%;
|
||||
max-height: 120px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.case-price-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: var(--bg-dark);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: var(--success-color);
|
||||
border: 1px solid var(--success-color);
|
||||
}
|
||||
|
||||
.case-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.case-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.case-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.case-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.case-rarities-mini {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rarity-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.case-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.case-price {
|
||||
font-weight: bold;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-open {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-open:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.2f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="cases-container">
|
||||
<div class="container">
|
||||
<h1>📦 Кейсы</h1>
|
||||
<p class="page-subtitle">Выберите кейс для открытия</p>
|
||||
|
||||
<div class="cases-sections">
|
||||
{% for section in sections %}
|
||||
<div class="section-block">
|
||||
<h2 class="section-tab">{{ section.tab }}</h2>
|
||||
<div class="section-cases">
|
||||
{% for case in section.cases %}
|
||||
<a href="/case/{{ case.name|urlencode }}" class="case-card-new">
|
||||
<div class="case-image">
|
||||
{% if case.image_url %}
|
||||
<img src="{{ case.image_url }}" alt="{{ case.display_name }}" onerror="this.src='/static/placeholder.png'">
|
||||
{% else %}
|
||||
<span style="font-size: 3rem;">📦</span>
|
||||
{% endif %}
|
||||
<span class="case-price-badge">{{ "%.0f"|format(case.price) }} ₽</span>
|
||||
</div>
|
||||
<div class="case-info">
|
||||
<div class="case-title">{{ case.display_name }}</div>
|
||||
{% if case.description %}
|
||||
<div class="case-description">{{ case.description }}</div>
|
||||
{% endif %}
|
||||
<div class="case-stats">
|
||||
<span>{{ case.items_count }} предметов</span>
|
||||
<div class="case-rarities-mini">
|
||||
{% for rarity in case.rarities[:4] %}
|
||||
<span class="rarity-dot" style="background: var(--rarity-{{ rarity|lower|replace(' ', '-')|replace(' grade', '') }});" title="{{ rarity }}"></span>
|
||||
{% endfor %}
|
||||
{% if case.rarities|length > 4 %}
|
||||
<span style="font-size: 0.7rem;">+{{ case.rarities|length - 4 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="case-footer">
|
||||
<span class="case-price">{{ "%.0f"|format(case.price) }} ₽</span>
|
||||
<span class="btn-open">Открыть</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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/safemode.js"></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>
|
||||
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,738 @@
|
||||
<!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">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link active">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link active">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.ct-page { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
|
||||
.ct-header { text-align: center; margin-bottom: 1.5rem; }
|
||||
.ct-header h1 { font-size: 1.6rem; font-family: 'Russo One', sans-serif; letter-spacing: 0.05em; }
|
||||
.ct-header h1 span { color: var(--primary-color); }
|
||||
.ct-header p { color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.2rem; }
|
||||
|
||||
/* ─── Two-panel layout ─── */
|
||||
.ct-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media(max-width:800px){
|
||||
.ct-layout{grid-template-columns:1fr}
|
||||
.ct-sel-panel{order:-1}
|
||||
}
|
||||
|
||||
/* ─── Left: Inventory panel ─── */
|
||||
.ct-inv-panel {
|
||||
background: rgba(20,21,26,0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ct-inv-toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: rgba(0,0,0,0.15);
|
||||
}
|
||||
.ct-inv-toolbar select, .ct-inv-toolbar input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.ct-inv-toolbar select:focus, .ct-inv-toolbar input:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.ct-inv-toolbar input {
|
||||
flex: 1; min-width: 100px;
|
||||
}
|
||||
|
||||
.ct-inv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ct-inv-grid::-webkit-scrollbar { width: 4px; }
|
||||
.ct-inv-grid::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
|
||||
|
||||
.ct-card {
|
||||
position: relative;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.4rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
user-select: none;
|
||||
animation: ctCardIn 0.25s ease both;
|
||||
}
|
||||
@keyframes ctCardIn { from{opacity:0;transform:translateY(8px) scale(0.95)} to{opacity:1;transform:translateY(0) scale(1)} }
|
||||
.ct-card:hover { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); }
|
||||
.ct-card:active { transform: scale(0.96); }
|
||||
.ct-card img {
|
||||
width: 100%; height: 56px;
|
||||
object-fit: contain; pointer-events: none;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.ct-card:hover img { transform: scale(1.06); }
|
||||
.ct-card .ct-name {
|
||||
font-size: 0.6rem; text-align: center; line-height: 1.1;
|
||||
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
.ct-card .ct-float { font-size: 0.55rem; color: var(--text-secondary); }
|
||||
|
||||
.ct-card.selected {
|
||||
border-color: var(--success-color);
|
||||
background: rgba(16,185,129,0.08);
|
||||
}
|
||||
.ct-card.spent {
|
||||
opacity: 0.2;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ct-card.spent::after {
|
||||
content: '✔';
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
font-size: 0.65rem; color: var(--success-color);
|
||||
font-weight: 800;
|
||||
}
|
||||
.ct-card.blocked {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
.ct-card.knife-item { border-color: rgba(255,215,0,0.12); }
|
||||
.ct-card .ct-knife-badge {
|
||||
position: absolute; top: 3px; left: 3px;
|
||||
font-size: 0.55rem; padding: 0.05rem 0.25rem;
|
||||
background: rgba(255,215,0,0.15); color: #ffd700;
|
||||
border-radius: 3px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* ─── Right: Selection panel ─── */
|
||||
.ct-sel-panel {
|
||||
background: rgba(20,21,26,0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
position: sticky; top: 80px;
|
||||
min-height: 300px;
|
||||
}
|
||||
.ct-sel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.ct-sel-header h3 {
|
||||
font-family: 'Russo One', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.ct-sel-header .ct-sel-count {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Russo One', sans-serif;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.ct-sel-count.full { color: var(--success-color); }
|
||||
|
||||
.ct-sel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.ct-sel-slot {
|
||||
background: rgba(0,0,0,0.15);
|
||||
border: 1px dashed rgba(255,255,255,0.06);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem;
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
.ct-sel-slot.fill {
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
border-style: solid;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.ct-sel-slot img {
|
||||
width: 100%; height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.ct-sel-slot .s-name {
|
||||
font-size: 0.48rem; text-align: center; line-height: 1.05;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%;
|
||||
}
|
||||
.ct-sel-slot .s-remove {
|
||||
position: absolute; top: 1px; right: 1px;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: rgba(239,68,68,0.8); color: white;
|
||||
border: none; font-size: 0.55rem; cursor: pointer;
|
||||
display: none; align-items: center; justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.ct-sel-slot.fill .s-remove { display: flex; }
|
||||
.ct-sel-slot .s-remove:hover { background: rgba(239,68,68,1); transform: scale(1.15); }
|
||||
.ct-sel-slot .s-num {
|
||||
font-size: 0.5rem; color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ct-sel-actions {
|
||||
display: flex; flex-direction: column; gap: 0.4rem;
|
||||
}
|
||||
.ct-sel-actions button {
|
||||
width: 100%; padding: 0.5rem;
|
||||
border: none; border-radius: 8px;
|
||||
font-family: 'Russo One', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
.ct-sel-btn {
|
||||
background: linear-gradient(135deg, var(--success-color), #059669);
|
||||
color: white;
|
||||
}
|
||||
.ct-sel-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 4px 20px rgba(16,185,129,0.3);
|
||||
}
|
||||
.ct-sel-btn:disabled {
|
||||
opacity: 0.3; cursor: not-allowed;
|
||||
}
|
||||
.ct-sel-btn.knife {
|
||||
background: linear-gradient(135deg, #ffd700, #f97316);
|
||||
}
|
||||
.ct-sel-btn.knife:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 20px rgba(255,215,0,0.3);
|
||||
}
|
||||
.ct-sel-clear {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--border-color) !important;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ct-sel-clear:hover { border-color: var(--danger-color) !important; color: var(--danger-color); }
|
||||
|
||||
.ct-sel-hint {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* ─── Modals (unchanged) ─── */
|
||||
.ct-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.7); backdrop-filter: blur(8px);
|
||||
z-index: 200; display: none; align-items: center; justify-content: center;
|
||||
padding: 1rem; animation: ctOvlIn 0.25s ease;
|
||||
}
|
||||
.ct-overlay.open { display: flex; }
|
||||
@keyframes ctOvlIn { from{opacity:0} to{opacity:1} }
|
||||
|
||||
.ct-paper {
|
||||
background: linear-gradient(145deg, #1c1d24, #14151a);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 20px; max-width: 640px; width: 100%; padding: 2rem;
|
||||
position: relative; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
animation: ctPaperIn 0.4s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
@keyframes ctPaperIn { from{opacity:0;transform:scale(0.92) translateY(20px)} to{opacity:1;transform:scale(1) translateY(0)} }
|
||||
|
||||
.ct-paper-title { text-align: center; font-family:'Russo One',sans-serif; font-size:1.3rem; letter-spacing:0.08em; }
|
||||
.ct-paper-sub { text-align: center; font-size:0.75rem; color:var(--text-secondary); letter-spacing:0.05em; margin-bottom:1.5rem; }
|
||||
.ct-paper-line { height:1px; background:linear-gradient(90deg,transparent,rgba(255,255,255,0.08),transparent); margin-bottom:1.25rem; }
|
||||
|
||||
.ct-paper-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:0.5rem; margin-bottom:1.25rem; }
|
||||
.ct-paper-slot {
|
||||
background:rgba(0,0,0,0.15); border:1px dashed rgba(255,255,255,0.06);
|
||||
border-radius:8px; padding:0.35rem; min-height:60px;
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:0.15rem; transition:all 0.3s;
|
||||
}
|
||||
.ct-paper-slot.fill { border-color:rgba(255,255,255,0.12); border-style:solid; background:rgba(255,255,255,0.02); }
|
||||
.ct-paper-slot img { width:100%; height:30px; object-fit:contain; }
|
||||
.ct-paper-slot .s-name { font-size:0.5rem; text-align:center; line-height:1.1; }
|
||||
|
||||
.ct-sign-area { display:flex; align-items:center; gap:0.75rem; margin-bottom:0.75rem; }
|
||||
.ct-sign-canvas { flex:1; border:1px dashed rgba(255,255,255,0.1); border-radius:6px; cursor:crosshair; background:#0d0e12; height:50px; }
|
||||
|
||||
.ct-stamp { text-align:center; font-family:'Russo One',sans-serif; font-size:1.4rem; letter-spacing:0.15em; color:var(--success-color); opacity:0; transition:opacity 0.5s; }
|
||||
.ct-stamp.active { opacity:1; }
|
||||
|
||||
.ct-sign-btn {
|
||||
width:100%; padding:0.7rem; border:none; border-radius:10px;
|
||||
background:linear-gradient(135deg,var(--primary-color),#f97316); color:white;
|
||||
font-family:'Russo One',sans-serif; font-size:0.85rem; letter-spacing:0.06em;
|
||||
cursor:pointer; transition:all 0.25s; margin-top:0.75rem;
|
||||
}
|
||||
.ct-sign-btn:hover{filter:brightness(1.1);box-shadow:0 4px 24px rgba(245,158,11,0.3)}
|
||||
.ct-sign-btn:disabled{opacity:0.5;cursor:not-allowed;filter:none;box-shadow:none}
|
||||
|
||||
.ct-result { text-align:center; }
|
||||
.ct-result-img { width:160px; height:120px; object-fit:contain; margin-bottom:0.75rem; }
|
||||
.ct-result-name { font-size:1.15rem; font-weight:700; margin-bottom:0.3rem; }
|
||||
.ct-result-rarity { font-size:0.85rem; font-weight:600; margin-bottom:0.4rem; }
|
||||
.ct-result-detail { font-size:0.75rem; color:var(--text-secondary); margin-bottom:0.15rem; }
|
||||
.ct-result-btn {
|
||||
margin-top:1.25rem; padding:0.6rem 1.8rem; border:none; border-radius:8px;
|
||||
background:linear-gradient(135deg,var(--primary-color),#f97316); color:white;
|
||||
font-family:'Russo One',sans-serif; font-size:0.85rem; cursor:pointer; transition:all 0.25s;
|
||||
}
|
||||
.ct-result-btn:hover{filter:brightness(1.1);box-shadow:0 4px 24px rgba(245,158,11,0.3)}
|
||||
|
||||
.ct-empty {
|
||||
text-align: center; padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ct-empty h3 { margin-bottom: 0.5rem; }
|
||||
.ct-empty p { font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
|
||||
<div class="ct-page">
|
||||
<div class="ct-header">
|
||||
<h1>🔄 <span>Trade Up</span> Контракт</h1>
|
||||
<p>Выберите 10 предметов одинаковой редкости и типа</p>
|
||||
</div>
|
||||
|
||||
{% if inventory_items and inventory_items|length >= 10 %}
|
||||
<div class="ct-layout">
|
||||
<!-- Left: Inventory -->
|
||||
<div class="ct-inv-panel">
|
||||
<div class="ct-inv-toolbar">
|
||||
<select id="ctRarityFilter" onchange="ctRenderInv()">
|
||||
<option value="">Все редкости</option>
|
||||
{% set rarities = inventory_items|map(attribute='rarity')|unique|list %}
|
||||
{% for r in rarities %}
|
||||
<option value="{{ r }}">{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="ctTypeFilter" onchange="ctRenderInv()">
|
||||
<option value="">Все типы</option>
|
||||
{% set types = inventory_items|map(attribute='type')|unique|list %}
|
||||
{% for t in types %}
|
||||
<option value="{{ t }}">{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" id="ctSearch" placeholder="🔍 Поиск..." oninput="ctRenderInv()">
|
||||
<select id="ctSort" onchange="ctRenderInv()">
|
||||
<option value="default">По умолчанию</option>
|
||||
<option value="price_asc">Цена ↑</option>
|
||||
<option value="price_desc">Цена ↓</option>
|
||||
<option value="name">По названию</option>
|
||||
<option value="float">Float</option>
|
||||
</select>
|
||||
<span style="font-size:0.7rem;color:var(--text-secondary)" id="ctInvCount"></span>
|
||||
</div>
|
||||
<div class="ct-inv-grid" id="ctInvGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Selection -->
|
||||
<div class="ct-sel-panel" id="ctSelPanel">
|
||||
<div class="ct-sel-header">
|
||||
<h3>📋 Контракт</h3>
|
||||
<span class="ct-sel-count" id="ctSelCount">0/10</span>
|
||||
</div>
|
||||
<div class="ct-sel-grid" id="ctSelGrid">
|
||||
{% for i in range(10) %}
|
||||
<div class="ct-sel-slot" id="ctSlot-{{ i }}" data-idx="{{ i }}">
|
||||
<span class="s-num">{{ i+1 }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="ct-sel-hint" id="ctSelHint">Выберите предмет из инвентаря</div>
|
||||
<div class="ct-sel-actions">
|
||||
<button class="ct-sel-clear" onclick="ctClearAll()">✕ Сбросить</button>
|
||||
<button class="ct-sel-btn" id="ctSubmitBtn" disabled onclick="ctOpenPaper()">🔄 Заключить контракт</button>
|
||||
{% if knife_count >= 10 %}
|
||||
<button class="ct-sel-btn knife" id="ctKnifeBtn" onclick="ctOpenKnife()">🔞 Крафт дилдок ×{{ knife_count }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="ct-empty">
|
||||
{% if user %}
|
||||
<div style="font-size:2rem;margin-bottom:0.5rem">📭</div>
|
||||
<h3>Недостаточно предметов</h3>
|
||||
<p>Нужно минимум 10 предметов одинаковой редкости и типа</p>
|
||||
<a href="/cases" class="btn btn-primary">Открыть кейсы</a>
|
||||
{% else %}
|
||||
<div style="font-size:2rem;margin-bottom:0.5rem">🔒</div>
|
||||
<h3>Контракты доступны только авторизованным</h3>
|
||||
<p>Войдите в аккаунт, чтобы просматривать инвентарь и заключать контракты</p>
|
||||
<a href="/login" class="btn btn-primary">Войти</a>
|
||||
<a href="/register" class="btn btn-outline">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Модалка бумаги -->
|
||||
<div class="ct-overlay" id="ctPaperModal">
|
||||
<div class="ct-paper">
|
||||
<div class="ct-paper-title">📜 TRADE UP CONTRACT</div>
|
||||
<div class="ct-paper-sub">CS2 Weapon Case — проверка подлинности</div>
|
||||
<div class="ct-paper-line"></div>
|
||||
<div class="ct-paper-grid" id="ctPaperSlots"></div>
|
||||
<div class="ct-paper-line"></div>
|
||||
<div class="ct-sign-area">
|
||||
<canvas class="ct-sign-canvas" id="ctSignCanvas" width="400" height="50"></canvas>
|
||||
<button class="ct-sel-clear" onclick="ctClearSign()" style="padding:0.3rem 0.6rem">✕</button>
|
||||
</div>
|
||||
<div class="ct-stamp" id="ctStamp">✔ УТВЕРЖДЕНО</div>
|
||||
<button class="ct-sign-btn" id="ctSignBtn" onclick="ctSign()">✍ ПОДПИСАТЬ</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалка результата -->
|
||||
<div class="ct-overlay" id="ctResultModal">
|
||||
<div class="ct-paper" style="max-width:420px">
|
||||
<div class="ct-result" id="ctResultContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── Данные ────────────────────────────────────────────────────────────
|
||||
const allItems = [
|
||||
{% for item in inventory_items %}
|
||||
{
|
||||
invId: {{ item.inventory_id }},
|
||||
name: {{ item.name|tojson }},
|
||||
rarity: {{ item.rarity|tojson }},
|
||||
type: {{ item.type|tojson }},
|
||||
float: {{ item.float }},
|
||||
image: {{ item.image_url|tojson }},
|
||||
isKnife: {{ 1 if item.is_knife else 0 }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
const selected = new Map(); // invId -> {data, idx}
|
||||
let ctMode = ''; // '' | 'knife' | 'normal'
|
||||
|
||||
// ─── Render inventory ──────────────────────────────────────────────────
|
||||
function ctRenderInv() {
|
||||
const rarity = document.getElementById('ctRarityFilter').value;
|
||||
const type = document.getElementById('ctTypeFilter').value;
|
||||
const q = document.getElementById('ctSearch').value.toLowerCase();
|
||||
const sort = document.getElementById('ctSort').value;
|
||||
|
||||
let items = allItems.filter(it => {
|
||||
if (selected.has(it.invId)) return false;
|
||||
if (rarity && it.rarity !== rarity) return false;
|
||||
if (type && it.type !== type) return false;
|
||||
if (q && !it.name.toLowerCase().includes(q)) return false;
|
||||
// Если уже есть выбор — показываем только совместимые
|
||||
if (ctMode === 'knife' && !it.isKnife) return false;
|
||||
if (ctMode === 'normal') {
|
||||
const first = selected.values().next().value;
|
||||
if (first && (it.isKnife || it.rarity !== first.data.rarity || it.type !== first.data.type)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
switch (sort) {
|
||||
case 'price_asc': items.sort((a,b) => (a.float||0) - (b.float||0)); break;
|
||||
case 'price_desc': items.sort((a,b) => (b.float||0) - (a.float||0)); break;
|
||||
case 'name': items.sort((a,b) => a.name.localeCompare(b.name)); break;
|
||||
case 'float': items.sort((a,b) => a.float - b.float); break;
|
||||
}
|
||||
|
||||
document.getElementById('ctInvCount').textContent = items.length;
|
||||
|
||||
const grid = document.getElementById('ctInvGrid');
|
||||
grid.innerHTML = items.map((it, i) => `
|
||||
<div class="ct-card ${it.isKnife ? 'knife-item' : ''}"
|
||||
onclick="ctPick(${it.invId})"
|
||||
style="animation-delay:${(i%30)*0.025}s"
|
||||
data-inv="${it.invId}">
|
||||
${it.isKnife ? '<div class="ct-knife-badge">🔪</div>' : ''}
|
||||
<img src="${it.image || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy">
|
||||
<div class="ct-name">${it.name}</div>
|
||||
<div class="ct-float">★ ${it.float.toFixed(4)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ─── Pick / Unpick ────────────────────────────────────────────────────
|
||||
function ctPick(invId) {
|
||||
const data = allItems.find(it => it.invId === invId);
|
||||
if (!data) return;
|
||||
|
||||
// Первый выбор — устанавливаем режим
|
||||
if (selected.size === 0) {
|
||||
ctMode = data.isKnife ? 'knife' : 'normal';
|
||||
}
|
||||
|
||||
// Проверка совместимости
|
||||
if (ctMode === 'knife' && !data.isKnife) return;
|
||||
if (ctMode === 'normal') {
|
||||
const first = selected.values().next().value;
|
||||
const ref = first ? first.data : data;
|
||||
if (data.isKnife || data.rarity !== ref.rarity || data.type !== ref.type) return;
|
||||
}
|
||||
|
||||
if (selected.size >= 10) return;
|
||||
|
||||
selected.set(invId, { data, idx: selected.size });
|
||||
ctRenderInv();
|
||||
ctRenderSel();
|
||||
}
|
||||
|
||||
function ctUnpick(invId) {
|
||||
if (!selected.has(invId)) return;
|
||||
selected.delete(invId);
|
||||
// Перенумеровать
|
||||
let i = 0;
|
||||
for (const [id, v] of selected) {
|
||||
v.idx = i++;
|
||||
}
|
||||
if (selected.size === 0) ctMode = '';
|
||||
ctRenderInv();
|
||||
ctRenderSel();
|
||||
}
|
||||
|
||||
function ctClearAll() {
|
||||
selected.clear();
|
||||
ctMode = '';
|
||||
ctRenderInv();
|
||||
ctRenderSel();
|
||||
}
|
||||
|
||||
// ─── Render selection panel ───────────────────────────────────────────
|
||||
function ctRenderSel() {
|
||||
const count = document.getElementById('ctSelCount');
|
||||
const hint = document.getElementById('ctSelHint');
|
||||
const submitBtn = document.getElementById('ctSubmitBtn');
|
||||
const knifeBtn = document.getElementById('ctKnifeBtn');
|
||||
|
||||
count.textContent = selected.size + '/10';
|
||||
count.classList.toggle('full', selected.size === 10);
|
||||
|
||||
if (selected.size === 0) {
|
||||
hint.style.display = 'block';
|
||||
submitBtn.style.display = 'none';
|
||||
if (knifeBtn) knifeBtn.style.display = 'none';
|
||||
} else {
|
||||
hint.style.display = 'none';
|
||||
if (ctMode === 'knife') {
|
||||
submitBtn.style.display = 'none';
|
||||
if (knifeBtn) knifeBtn.style.display = selected.size === 10 ? '' : 'none';
|
||||
} else {
|
||||
if (knifeBtn) knifeBtn.style.display = 'none';
|
||||
submitBtn.style.display = selected.size === 10 ? '' : 'none';
|
||||
}
|
||||
}
|
||||
submitBtn.disabled = selected.size !== 10;
|
||||
|
||||
// Fill slots
|
||||
const items = [...selected.entries()].sort((a,b) => a[1].idx - b[1].idx);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slot = document.getElementById('ctSlot-' + i);
|
||||
const entry = items[i];
|
||||
if (entry) {
|
||||
const [id, v] = entry;
|
||||
slot.className = 'ct-sel-slot fill';
|
||||
slot.innerHTML = `
|
||||
<button class="s-remove" onclick="ctUnpick(${id})">✕</button>
|
||||
<img src="${v.data.image || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="s-name">${v.data.name}</div>
|
||||
`;
|
||||
} else {
|
||||
slot.className = 'ct-sel-slot';
|
||||
slot.innerHTML = `<span class="s-num">${i+1}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('ctSubmitBtn').textContent = ctMode === 'knife' ? '🔞 Крафт дилдок' : '🔄 Заключить контракт';
|
||||
}
|
||||
|
||||
// ─── Paper modal ──────────────────────────────────────────────────────
|
||||
function ctOpenPaper() {
|
||||
if (selected.size !== 10) return;
|
||||
const items = [...selected.entries()].sort((a,b) => a[1].idx - b[1].idx).map(([id, v]) => ({
|
||||
invId: id,
|
||||
name: v.data.name,
|
||||
rarity: v.data.rarity,
|
||||
float: v.data.float,
|
||||
image: v.data.image
|
||||
}));
|
||||
window.__ctItems = items;
|
||||
|
||||
const slots = document.getElementById('ctPaperSlots');
|
||||
slots.innerHTML = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'ct-paper-slot';
|
||||
s.id = 'ctSlotPaper' + i;
|
||||
s.innerHTML = '<span style="font-size:0.5rem;color:var(--text-secondary)">' + (i+1) + '</span>';
|
||||
slots.appendChild(s);
|
||||
}
|
||||
|
||||
document.getElementById('ctPaperModal').classList.add('open');
|
||||
document.getElementById('ctStamp').classList.remove('active');
|
||||
setTimeout(ctInitSign, 100);
|
||||
ctAnimateSlots();
|
||||
}
|
||||
|
||||
function ctOpenKnife() {
|
||||
if (selected.size !== 10) return;
|
||||
ctOpenPaper();
|
||||
}
|
||||
|
||||
async function ctAnimateSlots() {
|
||||
const items = window.__ctItems || [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const s = document.getElementById('ctSlotPaper' + i);
|
||||
const item = items[i];
|
||||
s.classList.add('fill');
|
||||
s.innerHTML = `<img src="${item.image}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="s-name">${item.name}</div>`;
|
||||
await new Promise(r => setTimeout(r, 50 + Math.random() * 40));
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
document.getElementById('ctStamp').classList.add('active');
|
||||
}
|
||||
|
||||
let ctSignCtx = null;
|
||||
function ctInitSign() {
|
||||
const c = document.getElementById('ctSignCanvas');
|
||||
const ctx = c.getContext('2d');
|
||||
ctSignCtx = ctx;
|
||||
ctx.fillStyle = '#0d0e12'; ctx.fillRect(0,0,c.width,c.height);
|
||||
ctx.strokeStyle = '#d4c5a9'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
let drawing = false;
|
||||
const s = e => { const r=c.getBoundingClientRect(); drawing=true; ctx.beginPath(); ctx.moveTo(e.clientX-r.left, e.clientY-r.top); };
|
||||
const m = e => { if(!drawing)return; const r=c.getBoundingClientRect(); ctx.lineTo(e.clientX-r.left, e.clientY-r.top); ctx.stroke(); };
|
||||
const st = () => drawing = false;
|
||||
c.onmousedown = s; c.onmousemove = m; c.onmouseup = st; c.onmouseleave = st;
|
||||
c.ontouchstart = e=>{e.preventDefault();const t=e.touches[0];const r=c.getBoundingClientRect();drawing=true;ctx.beginPath();ctx.moveTo(t.clientX-r.left,t.clientY-r.top);};
|
||||
c.ontouchmove = e=>{e.preventDefault();if(!drawing)return;const t=e.touches[0];const r=c.getBoundingClientRect();ctx.lineTo(t.clientX-r.left,t.clientY-r.top);ctx.stroke();};
|
||||
c.ontouchend = ()=>drawing=false;
|
||||
}
|
||||
function ctClearSign() {
|
||||
const c = document.getElementById('ctSignCanvas');
|
||||
if(ctSignCtx){ctSignCtx.fillStyle='#0d0e12';ctSignCtx.fillRect(0,0,c.width,c.height);}
|
||||
}
|
||||
|
||||
async function ctSign() {
|
||||
const btn = document.getElementById('ctSignBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ ПОДПИСЫВАЕМ...';
|
||||
SoundManager.contractSubmit();
|
||||
const items = window.__ctItems || [];
|
||||
const fd = new FormData();
|
||||
fd.append('inventory_ids', JSON.stringify(items.map(i => i.invId)));
|
||||
try {
|
||||
const res = await fetch('/web/api/contracts/submit', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
if (typeof handleAchievements === 'function') handleAchievements(data);
|
||||
SoundManager.contractResult();
|
||||
ctClosePaper();
|
||||
setTimeout(() => ctShowResult(data.received_item, data.is_knife_contract), 200);
|
||||
} else {
|
||||
SoundManager.error(); alert(data.error || 'Ошибка'); ctClosePaper();
|
||||
}
|
||||
} catch(_) { SoundManager.error(); alert('Ошибка соединения'); ctClosePaper(); }
|
||||
btn.disabled = false; btn.textContent = '✍ ПОДПИСАТЬ';
|
||||
}
|
||||
|
||||
function ctShowResult(item, isKnife) {
|
||||
const g = getComputedStyle(document.documentElement).getPropertyValue('--rarity-' + (item.rarity||'').toLowerCase().replace(/ /g, '-').split('-')[0]).trim() || '#ffd700';
|
||||
const title = isKnife ? '🔞 КОНТРАКТ ВЫПОЛНЕН' : '🎉 КОНТРАКТ ВЫПОЛНЕН';
|
||||
document.getElementById('ctResultContent').innerHTML = `
|
||||
<div style="--g:${g}">
|
||||
<div style="font-size:2rem;margin-bottom:0.5rem">${isKnife ? '🔞' : '🎉'}</div>
|
||||
<div style="font-family:'Russo One',sans-serif;font-size:1.1rem;letter-spacing:0.04em;margin-bottom:1rem">${title}</div>
|
||||
<img class="ct-result-img" src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" style="filter:drop-shadow(0 0 30px var(--g))">
|
||||
<div class="ct-result-name">${item.name}</div>
|
||||
<div class="ct-result-rarity" style="color:${g}">${item.rarity}</div>
|
||||
<div class="ct-result-detail">Float: ${item.float.toFixed(6)}</div>
|
||||
<div class="ct-result-detail">Шанс: ${(item.probability || 0).toFixed(2)}%</div>
|
||||
<button class="ct-result-btn" onclick="ctCloseResult()">Продолжить</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('ctResultModal').classList.add('open');
|
||||
}
|
||||
|
||||
function ctClosePaper() { document.getElementById('ctPaperModal').classList.remove('open'); }
|
||||
function ctCloseResult() { document.getElementById('ctResultModal').classList.remove('open'); window.location.reload(); }
|
||||
|
||||
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 || ''));
|
||||
}
|
||||
function toggleSound() {
|
||||
const e = SoundManager.toggle();
|
||||
document.getElementById('soundToggle').textContent = e ? '🔊' : '🔇';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('soundToggle');
|
||||
if(btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
|
||||
ctRenderInv();
|
||||
ctRenderSel();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.ct-overlay').forEach(m => {
|
||||
m.addEventListener('click', e => { if(e.target === m) m.classList.remove('open'); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,518 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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">
|
||||
<style>
|
||||
.crash-layout { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.crash-canvas-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
#crashCanvas { width: 100%; height: 350px; display: block; border-radius: 8px; }
|
||||
.crash-overlay {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.crash-multiplier { font-size: 4rem; font-weight: 800; text-shadow: 0 0 40px rgba(34,197,94,0.5); transition: all 0.1s; }
|
||||
.crash-multiplier.crashed { color: #ef4444; text-shadow: 0 0 40px rgba(239,68,68,0.5); animation: crashShake 0.5s ease; }
|
||||
.crash-multiplier.waiting { font-size: 2rem; color: var(--text-secondary); text-shadow: none; }
|
||||
.crash-multiplier.betting { font-size: 3rem; color: var(--primary-color); text-shadow: 0 0 30px rgba(59,130,246,0.3); }
|
||||
@keyframes crashShake {
|
||||
0%,100%{transform:translateX(0)}10%{transform:translateX(-10px)rotate(-2deg)}30%{transform:translateX(10px)rotate(2deg)}50%{transform:translateX(-8px)rotate(-1deg)}70%{transform:translateX(8px)rotate(1deg)}90%{transform:translateX(-4px)}
|
||||
}
|
||||
.crash-status-text { font-size: 1rem; color: var(--text-secondary); margin-top: 0.5rem; }
|
||||
.crash-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.crash-bet-panel { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 1.5rem; }
|
||||
.crash-players-panel { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 1.5rem; max-height: 300px; overflow-y: auto; }
|
||||
.crash-bet-input { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.crash-bet-input input { flex: 1; padding: 0.75rem; background: var(--bg-dark); border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-color); font-size: 1.1rem; }
|
||||
.quick-bet-btns { display: grid; grid-template-columns: repeat(4,1fr); gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.quick-bet-btn { padding: 0.5rem; background: var(--bg-dark); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-color); cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
|
||||
.quick-bet-btn:hover { border-color: var(--primary-color); }
|
||||
.crash-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||||
.crash-btn.bet { background: var(--primary-color); color: white; }
|
||||
.crash-btn.bet:hover { filter: brightness(1.1); }
|
||||
.crash-btn.bet.placed { background: #6366f1; }
|
||||
.crash-btn.cashout { background: var(--success-color); color: white; }
|
||||
.crash-btn.cashout:hover { filter: brightness(1.1); }
|
||||
.crash-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.crash-btn.hidden { display: none; }
|
||||
.player-row { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0; border-bottom: 1px solid var(--border-color); font-size: 0.85rem; }
|
||||
.player-row:last-child { border-bottom: none; }
|
||||
.player-row .payout { color: var(--success-color); font-weight: 600; }
|
||||
.player-row .bet-amount { color: var(--text-secondary); }
|
||||
.crash-history { display: flex; gap: 0.4rem; flex-wrap: wrap; padding: 0.5rem 0; }
|
||||
.crash-history-item { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||
.crash-history-item.win { background: rgba(34,197,94,0.15); color: var(--success-color); }
|
||||
.crash-history-item.loss { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||
.cashout-progress { margin-top: 0.75rem; display: none; }
|
||||
.cashout-progress.active { display: block; }
|
||||
.cashout-slider { width: 100%; height: 6px; background: var(--bg-dark); border-radius: 3px; position: relative; overflow: hidden; }
|
||||
.cashout-slider-fill { height: 100%; background: linear-gradient(90deg, var(--success-color), #f59e0b, #ef4444); border-radius: 3px; transition: width 0.1s; }
|
||||
.cashout-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem; }
|
||||
.crash-my-bet { margin-top: 0.5rem; padding: 0.5rem; background: var(--bg-dark); border-radius: 6px; text-align: center; font-size: 0.85rem; }
|
||||
.crash-my-bet .bet-highlight { color: var(--primary-color); font-weight: 600; }
|
||||
.crash-my-bet .win-highlight { color: var(--success-color); font-weight: 600; }
|
||||
.crash-my-bet .lose-highlight { color: #ef4444; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link active">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container" style="padding: 2rem 1rem;">
|
||||
<div class="crash-layout">
|
||||
<div class="crash-canvas-container">
|
||||
<canvas id="crashCanvas"></canvas>
|
||||
<div class="crash-overlay">
|
||||
<div class="crash-multiplier waiting" id="crashMultiplier">Ожидание...</div>
|
||||
<div class="crash-status-text" id="crashStatus">Следующий раунд скоро начнётся</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="crash-history" id="crashHistory"></div>
|
||||
|
||||
<div class="crash-controls">
|
||||
<div class="crash-bet-panel">
|
||||
<h3>💰 Сделать ставку</h3>
|
||||
<div class="crash-bet-input">
|
||||
<input type="number" id="betAmount" value="100" min="1" step="1">
|
||||
</div>
|
||||
<div class="quick-bet-btns">
|
||||
<button class="quick-bet-btn" onclick="setBet(50)">50</button>
|
||||
<button class="quick-bet-btn" onclick="setBet(100)">100</button>
|
||||
<button class="quick-bet-btn" onclick="setBet(500)">500</button>
|
||||
<button class="quick-bet-btn" onclick="setBet(1000)">1K</button>
|
||||
</div>
|
||||
<button class="crash-btn bet" id="betBtn" onclick="placeBet()">Сделать ставку</button>
|
||||
<button class="crash-btn cashout hidden" id="cashoutBtn" onclick="cashOut()">💰 Забрать</button>
|
||||
<div id="myBetDisplay" class="crash-my-bet" style="display:none;"></div>
|
||||
<div class="cashout-progress" id="cashoutProgress">
|
||||
<div class="cashout-slider">
|
||||
<div class="cashout-slider-fill" id="cashoutSliderFill" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="cashout-label">
|
||||
<span>1.00x</span>
|
||||
<span id="cashoutMultLabel">1.00x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="crash-players-panel">
|
||||
<h3>👥 Ставки</h3>
|
||||
<div id="playersList">
|
||||
<div style="color: var(--text-secondary); text-align: center; padding: 1rem;">
|
||||
Ожидание ставок...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
const canvas = document.getElementById('crashCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const multiplierEl = document.getElementById('crashMultiplier');
|
||||
const statusEl = document.getElementById('crashStatus');
|
||||
const betBtn = document.getElementById('betBtn');
|
||||
const cashoutBtn = document.getElementById('cashoutBtn');
|
||||
const playersList = document.getElementById('playersList');
|
||||
const historyEl = document.getElementById('crashHistory');
|
||||
const betInput = document.getElementById('betAmount');
|
||||
const myBetDisplay = document.getElementById('myBetDisplay');
|
||||
const cashoutProgress = document.getElementById('cashoutProgress');
|
||||
const cashoutSliderFill = document.getElementById('cashoutSliderFill');
|
||||
const cashoutMultLabel = document.getElementById('cashoutMultLabel');
|
||||
|
||||
let gameState = null;
|
||||
let myActiveBet = null; // текущая ставка в этом раунде
|
||||
let myCashoutMult = null; // на каком множителе вывел (null = не вывел)
|
||||
let lastPhase = null;
|
||||
|
||||
function resizeCanvas() {
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = canvas.clientWidth || rect.width - 32;
|
||||
canvas.height = canvas.clientHeight || 350;
|
||||
}
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
function setBet(amount) { betInput.value = amount; SoundManager.click(); }
|
||||
|
||||
function expMult(t) { return Math.exp(t * 0.08); }
|
||||
function timeForMult(m) { return Math.log(m) / 0.08; }
|
||||
|
||||
let graphPoints = [];
|
||||
const GRAPH_WINDOW = 15; // секунд в окне
|
||||
|
||||
function drawGraph() {
|
||||
const w = canvas.width, h = canvas.height, pad = 40;
|
||||
const drawW = w - pad * 2, drawH = h - pad * 2;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (!gameState || gameState.phase === 'waiting') {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
ctx.font = '20px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('💥 Crash', w/2, h/2);
|
||||
return;
|
||||
}
|
||||
|
||||
const phase = gameState.phase;
|
||||
const curMult = gameState.current_multiplier || 1;
|
||||
const crashPoint = gameState.crash_point || 100;
|
||||
const isCrashed = phase === 'crashed';
|
||||
const isRunning = phase === 'running';
|
||||
|
||||
// Добавляем текущую точку в историю
|
||||
if (isRunning && curMult > 1) {
|
||||
const now = performance.now() / 1000;
|
||||
graphPoints.push({ t: now, m: curMult });
|
||||
// Отсекаем точки за пределами окна
|
||||
const cutoff = now - GRAPH_WINDOW;
|
||||
graphPoints = graphPoints.filter(p => p.t >= cutoff);
|
||||
if (graphPoints.length > 500) graphPoints = graphPoints.slice(-500);
|
||||
} else if (isCrashed) {
|
||||
graphPoints = [];
|
||||
}
|
||||
|
||||
// Определяем диапазон мультипликаторов для оси Y
|
||||
let maxY = 2;
|
||||
if (graphPoints.length > 0) {
|
||||
maxY = Math.max(...graphPoints.map(p => p.m));
|
||||
}
|
||||
maxY = Math.max(maxY, curMult + 1, crashPoint, 3);
|
||||
maxY = Math.min(maxY, 200); // не растягивать до небес
|
||||
maxY = Math.ceil(maxY);
|
||||
|
||||
// Сетка
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
const gridLines = 5;
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 1; i <= gridLines; i++) {
|
||||
const y = h - pad - (i / gridLines) * drawH;
|
||||
ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke();
|
||||
const multLabel = 1 + (maxY - 1) * (i / gridLines);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.12)';
|
||||
ctx.fillText(`x${multLabel.toFixed(1)}`, pad - 6, y + 3);
|
||||
}
|
||||
|
||||
// Рисуем кривую
|
||||
if (graphPoints.length < 2) {
|
||||
if (isCrashed) {
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.5)';
|
||||
ctx.font = '36px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`💥 x${crashPoint.toFixed(2)}`, w/2, h/2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const color = isCrashed ? '#ef4444' : '#22c55e';
|
||||
const now = performance.now() / 1000;
|
||||
const windowStart = now - GRAPH_WINDOW;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 10;
|
||||
|
||||
let first = true;
|
||||
for (const p of graphPoints) {
|
||||
const x = pad + ((p.t - windowStart) / GRAPH_WINDOW) * drawW;
|
||||
const y = h - pad - ((p.m - 1) / (maxY - 1)) * drawH;
|
||||
if (first) { ctx.moveTo(x, y); first = false; }
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Текущая точка (справа, на последнем значении)
|
||||
if (isRunning && curMult > 1) {
|
||||
const lastPt = graphPoints[graphPoints.length - 1];
|
||||
const x = pad + ((lastPt.t - windowStart) / GRAPH_WINDOW) * drawW;
|
||||
const y = h - pad - ((lastPt.m - 1) / (maxY - 1)) * drawH;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#22c55e';
|
||||
ctx.fill();
|
||||
ctx.shadowColor = '#22c55e';
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(34,197,94,0.4)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Беттинг фаза — таймер
|
||||
if (phase === 'betting') {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.font = '40px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`⏱ ${gameState.timer}`, w/2, h/2 + 10);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
if (!gameState) return;
|
||||
const phase = gameState.phase;
|
||||
|
||||
// Обновляем множитель и статус
|
||||
if (phase === 'waiting') {
|
||||
multiplierEl.className = 'crash-multiplier waiting';
|
||||
multiplierEl.textContent = 'Ожидание...';
|
||||
statusEl.textContent = 'Следующий раунд скоро начнётся';
|
||||
} else if (phase === 'betting') {
|
||||
multiplierEl.className = 'crash-multiplier betting';
|
||||
multiplierEl.textContent = `${gameState.timer}`;
|
||||
statusEl.textContent = 'Делайте ставки!';
|
||||
} else if (phase === 'running') {
|
||||
const mult = gameState.current_multiplier;
|
||||
multiplierEl.className = 'crash-multiplier';
|
||||
multiplierEl.textContent = `x${mult.toFixed(2)}`;
|
||||
statusEl.textContent = '🚀 Взлетаем!';
|
||||
if (myActiveBet && myCashoutMult === null) {
|
||||
const prog = Math.min(100, ((mult - 1) / 15) * 100);
|
||||
cashoutSliderFill.style.width = `${prog}%`;
|
||||
cashoutMultLabel.textContent = `x${mult.toFixed(2)}`;
|
||||
}
|
||||
} else if (phase === 'crashed') {
|
||||
multiplierEl.className = 'crash-multiplier crashed';
|
||||
multiplierEl.textContent = `💥 x${gameState.crash_point.toFixed(2)}`;
|
||||
statusEl.textContent = 'Краш!';
|
||||
}
|
||||
|
||||
// Кнопки
|
||||
const canBet = phase === 'betting';
|
||||
const canCashout = phase === 'running' && myActiveBet !== null && myCashoutMult === null;
|
||||
|
||||
betBtn.classList.toggle('hidden', !canBet && !(phase === 'betting' && myActiveBet));
|
||||
if (canBet) {
|
||||
betBtn.disabled = false;
|
||||
betBtn.textContent = myActiveBet ? `Ставка: ${myActiveBet} ₽` : '💰 Сделать ставку';
|
||||
betBtn.className = 'crash-btn bet' + (myActiveBet ? ' placed' : '');
|
||||
}
|
||||
|
||||
cashoutBtn.classList.toggle('hidden', !canCashout);
|
||||
if (canCashout) {
|
||||
cashoutBtn.disabled = false;
|
||||
cashoutBtn.textContent = `💰 Забрать (x${gameState.current_multiplier.toFixed(2)})`;
|
||||
}
|
||||
|
||||
cashoutProgress.classList.toggle('active', phase === 'running' && myActiveBet !== null && myCashoutMult === null);
|
||||
|
||||
// Статус моей ставки
|
||||
if (myActiveBet !== null) {
|
||||
if (myCashoutMult !== null) {
|
||||
const payout = myActiveBet * myCashoutMult;
|
||||
myBetDisplay.style.display = 'block';
|
||||
myBetDisplay.innerHTML = `
|
||||
✅ Вывел(а) <span class="win-highlight">${payout.toFixed(0)} ₽</span>
|
||||
на x${myCashoutMult.toFixed(2)}
|
||||
`;
|
||||
} else if (phase === 'crashed') {
|
||||
myBetDisplay.style.display = 'block';
|
||||
myBetDisplay.innerHTML = `
|
||||
❌ Проиграно <span class="lose-highlight">${myActiveBet.toFixed(0)} ₽</span>
|
||||
`;
|
||||
} else if (phase === 'running') {
|
||||
const potential = myActiveBet * gameState.current_multiplier;
|
||||
myBetDisplay.style.display = 'block';
|
||||
myBetDisplay.innerHTML = `
|
||||
Ставка: <span class="bet-highlight">${myActiveBet.toFixed(0)} ₽</span>
|
||||
→ ${potential.toFixed(0)} ₽
|
||||
`;
|
||||
} else {
|
||||
myBetDisplay.style.display = 'block';
|
||||
myBetDisplay.innerHTML = `
|
||||
Ставка: <span class="bet-highlight">${myActiveBet.toFixed(0)} ₽</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
myBetDisplay.style.display = 'none';
|
||||
}
|
||||
|
||||
// Звуки на смену фазы
|
||||
if (lastPhase !== phase) {
|
||||
if (phase === 'running') SoundManager.crashBet();
|
||||
if (phase === 'crashed') SoundManager.crashCrashed();
|
||||
lastPhase = phase;
|
||||
}
|
||||
|
||||
updatePlayers();
|
||||
updateHistory();
|
||||
drawGraph();
|
||||
}
|
||||
|
||||
function updatePlayers() {
|
||||
if (!gameState) return;
|
||||
const bc = gameState.bet_count || 0;
|
||||
if (bc === 0) {
|
||||
playersList.innerHTML = '<div style="color:var(--text-secondary);text-align:center;padding:1rem;">Пока нет ставок</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cashed = gameState.cashed_out || {};
|
||||
const cashCount = Object.keys(cashed).length;
|
||||
|
||||
let html = `<div class="player-row" style="font-weight:600;border-bottom:2px solid var(--border-color);">
|
||||
<span>Ставок: ${bc}</span>
|
||||
<span>Сумма: ${(gameState.total_bets || 0).toFixed(0)} ₽</span>
|
||||
<span>Вывели: ${cashCount}</span>
|
||||
</div>`;
|
||||
|
||||
if (gameState.phase === 'running' || gameState.phase === 'crashed') {
|
||||
const mult = gameState.current_multiplier || gameState.crash_point;
|
||||
for (const [uid, m] of Object.entries(cashed)) {
|
||||
const isMe = myActiveBet !== null && parseInt(uid) === 0;
|
||||
html += `<div class="player-row" style="${isMe ? 'color:var(--success-color)' : ''}">
|
||||
<span>${isMe ? 'Вы' : 'Игрок #' + uid.slice(-4)}</span>
|
||||
<span class="payout">✅ x${m.toFixed(2)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
playersList.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateHistory() {
|
||||
if (!gameState || !gameState.history) return;
|
||||
historyEl.innerHTML = gameState.history.slice().reverse().map(m => {
|
||||
const isWin = m > 2;
|
||||
return `<span class="crash-history-item ${isWin ? 'win' : 'loss'}">x${m.toFixed(2)}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// WebSocket events
|
||||
WS.on('crash_state', (data) => {
|
||||
if (!data.game) return;
|
||||
gameState = data.game;
|
||||
// При краше сбрасываем мою ставку т.к. раунд закончился
|
||||
if (gameState.phase === 'crashed' && lastPhase === 'running') {
|
||||
// Если не вывел и краш — ставка проиграна, но стейт остается
|
||||
}
|
||||
if (gameState.phase === 'waiting' || gameState.phase === 'betting') {
|
||||
// Новый раунд — если у меня была ставка, её уже нет (проиграна/выведена)
|
||||
if (lastPhase === 'crashed' || lastPhase === 'running') {
|
||||
if (myCashoutMult === null && myActiveBet !== null && gameState.phase === 'betting') {
|
||||
// Ставка проиграна в прошлом раунде
|
||||
}
|
||||
}
|
||||
if (gameState.phase === 'betting') {
|
||||
// Если начался новый раунд betting и мы не обнулились —
|
||||
// это новый раунд. Старая ставка аннулируется.
|
||||
if (lastPhase === 'crashed' || lastPhase === 'waiting') {
|
||||
myActiveBet = null;
|
||||
myCashoutMult = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
updateUI();
|
||||
});
|
||||
|
||||
async function placeBet() {
|
||||
const amount = parseInt(betInput.value);
|
||||
if (!amount || amount <= 0) {
|
||||
SoundManager.error();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('amount', amount);
|
||||
|
||||
try {
|
||||
const res = await fetch('/web/api/crash/bet', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
myActiveBet = data.bet;
|
||||
myCashoutMult = null;
|
||||
SoundManager.crashBet();
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.new_balance)} ₽`;
|
||||
});
|
||||
updateUI();
|
||||
} else {
|
||||
SoundManager.error();
|
||||
alert(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
SoundManager.error();
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
async function cashOut() {
|
||||
if (!myActiveBet || myCashoutMult !== null) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/web/api/crash/cashout', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
myCashoutMult = data.multiplier;
|
||||
SoundManager.crashCashout();
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.new_balance)} ₽`;
|
||||
});
|
||||
updateUI();
|
||||
} else {
|
||||
SoundManager.error();
|
||||
alert(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
SoundManager.error();
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
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>
|
||||
{% include '_activity_sidebar.html' %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="hero">
|
||||
<div class="container">
|
||||
<h1>CS2 Trade-Up Simulator</h1>
|
||||
<p class="hero-subtitle">Открывай кейсы, собирай скины и создавай контракты</p>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📦</div>
|
||||
<h3>Открывай кейсы</h3>
|
||||
<p>Испытай удачу и получи редкие скины</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔄</div>
|
||||
<h3>Создавай контракты</h3>
|
||||
<p>Обменивай 10 скинов на один более редкий</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>Собирай коллекцию</h3>
|
||||
<p>Отслеживай свой прогресс и статистику</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-actions">
|
||||
<a href="/register" class="btn btn-primary btn-large">Начать играть</a>
|
||||
<a href="/login" class="btn btn-outline btn-large">Уже есть аккаунт</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,802 @@
|
||||
<!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>
|
||||
.inventory-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.select-all-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-all-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sell-selected-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sell-selected-btn:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sell-selected-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sell-all-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: transparent;
|
||||
color: var(--danger-color);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sell-all-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.inventory-skin-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-select-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.inventory-skin-card.selected-for-sale {
|
||||
border-color: var(--danger-color);
|
||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.selected-count-badge {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.total-price-badge {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-sell-single {
|
||||
flex: 1;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-sell-single:hover {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bulk-sell-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.bulk-sell-content {
|
||||
background: var(--bg-card);
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.bulk-sell-content h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bulk-sell-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.bulk-sell-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bulk-sell-option:hover {
|
||||
background: var(--bg-light);
|
||||
}
|
||||
|
||||
.bulk-sell-option input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.bulk-sell-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.2f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="inventory-container">
|
||||
<div class="container">
|
||||
<div class="inventory-header">
|
||||
<div>
|
||||
<h1>🎒 Инвентарь</h1>
|
||||
<p class="page-subtitle">Всего предметов: <span id="totalItemsCount">{{ inventory_items|length }}</span></p>
|
||||
</div>
|
||||
|
||||
<div class="inventory-actions">
|
||||
<div class="selection-controls">
|
||||
<label class="select-all-checkbox">
|
||||
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()">
|
||||
<span>Выбрать все</span>
|
||||
</label>
|
||||
<span class="selected-count-badge" id="selectedCountBadge" style="display: none;">0</span>
|
||||
<span class="total-price-badge" id="totalPriceBadge" style="display: none;">0 ₽</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button class="sell-selected-btn" id="sellSelectedBtn" onclick="sellSelected()" disabled>
|
||||
💰 Продать выбранные
|
||||
</button>
|
||||
<button class="sell-all-btn" onclick="openBulkSellModal()">
|
||||
📦 Массовая продажа
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inventory-filters">
|
||||
<div class="filter-bar">
|
||||
<select id="rarityFilter" class="filter-select" onchange="filterItems()">
|
||||
<option value="">Все редкости</option>
|
||||
{% for rarity in rarities %}
|
||||
<option value="{{ rarity }}">{{ rarity }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<select id="typeFilter" class="filter-select" onchange="filterItems()">
|
||||
<option value="">Все типы</option>
|
||||
{% for type in types %}
|
||||
<option value="{{ type }}">{{ type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<input type="text" id="searchInput" class="filter-select"
|
||||
placeholder="Поиск по названию..." oninput="filterItems()">
|
||||
|
||||
<button onclick="resetFilters()" class="btn btn-outline">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if inventory_items %}
|
||||
<div class="inventory-grid-detailed" id="inventoryGrid">
|
||||
{% for item in inventory_items %}
|
||||
<div class="inventory-skin-card rarity-{{ item.rarity|lower|replace(' ', '-') }}"
|
||||
data-inventory-id="{{ item.id }}"
|
||||
data-rarity="{{ item.rarity }}"
|
||||
data-type="{{ item.type }}"
|
||||
data-name="{{ item.name|lower }}"
|
||||
data-price="{{ item.price_rub or 100 }}">
|
||||
<input type="checkbox" class="item-select-checkbox"
|
||||
onchange="onItemSelect(this)"
|
||||
data-id="{{ item.id }}"
|
||||
data-price="{{ item.price_rub or 100 }}">
|
||||
|
||||
<div class="skin-image-container">
|
||||
<img src="{{ item.image_url or '/static/placeholder.png' }}"
|
||||
alt="{{ item.name }}"
|
||||
class="skin-image"
|
||||
onerror="this.src='/static/placeholder.png'">
|
||||
</div>
|
||||
<div class="skin-info">
|
||||
<div class="skin-name" title="{{ item.name }}">{{ item.name }}</div>
|
||||
<div class="skin-details">
|
||||
<span class="rarity-{{ item.rarity|lower|replace(' ', '-') }}">{{ item.rarity }}</span>
|
||||
<span>Float: {{ "%.6f"|format(item.float) }}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top: 5px;">
|
||||
<span>{{ item.type }}</span>
|
||||
<span>{{ item.wear }}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top: 5px; color: var(--success-color);">
|
||||
<span>💰 {{ "%.0f"|format(item.price_rub or 100) }} ₽</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button onclick="sellSingleItem({{ item.id }})" class="btn-sell-single">
|
||||
Продать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state-large">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h3>Ваш инвентарь пуст</h3>
|
||||
<p>Откройте кейсы, чтобы получить первые предметы!</p>
|
||||
<a href="/cases" class="btn btn-primary">Открыть кейсы</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Модальное окно массовой продажи -->
|
||||
<div id="bulkSellModal" class="bulk-sell-modal" style="display: none;">
|
||||
<div class="bulk-sell-content">
|
||||
<h3>📦 Массовая продажа</h3>
|
||||
<p>Выберите редкости предметов для продажи:</p>
|
||||
|
||||
<div class="bulk-sell-options" id="bulkSellOptions">
|
||||
<!-- Опции будут добавлены через JS -->
|
||||
</div>
|
||||
|
||||
<div style="margin: 1rem 0;">
|
||||
<p>Будет продано: <strong id="bulkSellCount">0</strong> предметов</p>
|
||||
<p>Сумма: <strong id="bulkSellTotal" style="color: var(--success-color);">0 ₽</strong></p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedItems = new Set();
|
||||
let itemPrices = new Map();
|
||||
|
||||
// Инициализация цен
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
const id = parseInt(card.dataset.inventoryId);
|
||||
const price = parseFloat(card.dataset.price);
|
||||
itemPrices.set(id, price);
|
||||
});
|
||||
|
||||
function onItemSelect(checkbox) {
|
||||
const card = checkbox.closest('.inventory-skin-card');
|
||||
const itemId = parseInt(checkbox.dataset.id);
|
||||
|
||||
if (checkbox.checked) {
|
||||
selectedItems.add(itemId);
|
||||
card.classList.add('selected-for-sale');
|
||||
} else {
|
||||
selectedItems.delete(itemId);
|
||||
card.classList.remove('selected-for-sale');
|
||||
document.getElementById('selectAllCheckbox').checked = false;
|
||||
}
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const selectAll = document.getElementById('selectAllCheckbox');
|
||||
const checkboxes = document.querySelectorAll('.item-select-checkbox');
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
if (cb.closest('.inventory-skin-card').style.display !== 'none') {
|
||||
cb.checked = selectAll.checked;
|
||||
const card = cb.closest('.inventory-skin-card');
|
||||
const itemId = parseInt(cb.dataset.id);
|
||||
|
||||
if (selectAll.checked) {
|
||||
selectedItems.add(itemId);
|
||||
card.classList.add('selected-for-sale');
|
||||
} else {
|
||||
selectedItems.delete(itemId);
|
||||
card.classList.remove('selected-for-sale');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function updateSelectionUI() {
|
||||
const count = selectedItems.size;
|
||||
const countBadge = document.getElementById('selectedCountBadge');
|
||||
const priceBadge = document.getElementById('totalPriceBadge');
|
||||
const sellBtn = document.getElementById('sellSelectedBtn');
|
||||
|
||||
if (count > 0) {
|
||||
countBadge.style.display = 'inline-block';
|
||||
countBadge.textContent = `${count} выбрано`;
|
||||
|
||||
let total = 0;
|
||||
selectedItems.forEach(id => {
|
||||
total += itemPrices.get(id) || 100;
|
||||
});
|
||||
|
||||
priceBadge.style.display = 'inline-block';
|
||||
priceBadge.textContent = `${total.toFixed(0)} ₽`;
|
||||
|
||||
sellBtn.disabled = false;
|
||||
} else {
|
||||
countBadge.style.display = 'none';
|
||||
priceBadge.style.display = 'none';
|
||||
sellBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBalance() {
|
||||
try {
|
||||
const response = await fetch('/web/api/user/balance');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.balance)} ₽`;
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function sellSingleItem(inventoryId) {
|
||||
if (!confirm('Продать этот предмет?')) return;
|
||||
SoundManager.click();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_id', inventoryId);
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
SoundManager.balanceUpdate();
|
||||
const card = document.querySelector(`.inventory-skin-card[data-id="${inventoryId}"]`);
|
||||
if (card) {
|
||||
card.style.transition = 'all 0.3s';
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
}
|
||||
await refreshBalance();
|
||||
} else {
|
||||
SoundManager.error();
|
||||
alert(data.error || 'Ошибка при продаже');
|
||||
}
|
||||
} catch (error) {
|
||||
SoundManager.error();
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
async function sellSelected() {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
let total = 0;
|
||||
selectedItems.forEach(id => {
|
||||
total += itemPrices.get(id) || 100;
|
||||
});
|
||||
|
||||
if (!confirm(`Продать ${selectedItems.size} предметов за ${total.toFixed(0)} ₽?`)) return;
|
||||
SoundManager.click();
|
||||
|
||||
const ids = Array.from(selectedItems);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(ids));
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
selectedItems.forEach(id => {
|
||||
const card = document.querySelector(`.inventory-skin-card[data-id="${id}"]`);
|
||||
if (card) {
|
||||
card.style.transition = 'all 0.3s';
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
}
|
||||
});
|
||||
selectedItems.clear();
|
||||
await refreshBalance();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при продаже');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
function openBulkSellModal() {
|
||||
const modal = document.getElementById('bulkSellModal');
|
||||
const optionsDiv = document.getElementById('bulkSellOptions');
|
||||
|
||||
// Собираем уникальные редкости из видимых предметов
|
||||
const rarities = new Set();
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
rarities.add(card.dataset.rarity);
|
||||
}
|
||||
});
|
||||
|
||||
// Создаем опции
|
||||
optionsDiv.innerHTML = '';
|
||||
Array.from(rarities).sort().forEach(rarity => {
|
||||
const count = document.querySelectorAll(`.inventory-skin-card[data-rarity="${rarity}"]`).length;
|
||||
|
||||
const option = document.createElement('label');
|
||||
option.className = 'bulk-sell-option';
|
||||
option.innerHTML = `
|
||||
<input type="checkbox" value="${rarity}" onchange="updateBulkSellCount()">
|
||||
<span style="flex: 1;">
|
||||
<span class="rarity-${rarity.toLowerCase().replace(/ /g, '-')}">${rarity}</span>
|
||||
</span>
|
||||
<span style="color: var(--text-secondary);">${count} шт.</span>
|
||||
`;
|
||||
optionsDiv.appendChild(option);
|
||||
});
|
||||
|
||||
// Добавляем опцию "Все"
|
||||
const allOption = document.createElement('label');
|
||||
allOption.className = 'bulk-sell-option';
|
||||
allOption.innerHTML = `
|
||||
<input type="checkbox" value="all" onchange="toggleAllRarities(this)">
|
||||
<span style="flex: 1; font-weight: 500;">Все предметы</span>
|
||||
<span style="color: var(--text-secondary);" id="totalVisibleCount">0</span>
|
||||
`;
|
||||
optionsDiv.insertBefore(allOption, optionsDiv.firstChild);
|
||||
|
||||
updateVisibleCount();
|
||||
updateBulkSellCount();
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeBulkSellModal() {
|
||||
document.getElementById('bulkSellModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function toggleAllRarities(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('#bulkSellOptions input[type="checkbox"]');
|
||||
checkboxes.forEach(cb => {
|
||||
if (cb !== checkbox) {
|
||||
cb.checked = checkbox.checked;
|
||||
}
|
||||
});
|
||||
updateBulkSellCount();
|
||||
}
|
||||
|
||||
function updateVisibleCount() {
|
||||
const visibleCards = document.querySelectorAll('.inventory-skin-card:not([style*="display: none"])');
|
||||
document.getElementById('totalVisibleCount').textContent = visibleCards.length;
|
||||
}
|
||||
|
||||
function updateBulkSellCount() {
|
||||
const selectedRarities = [];
|
||||
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => {
|
||||
if (cb.value && cb.value !== 'all') {
|
||||
selectedRarities.push(cb.value);
|
||||
}
|
||||
});
|
||||
|
||||
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
|
||||
|
||||
let count = 0;
|
||||
let total = 0;
|
||||
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
const rarity = card.dataset.rarity;
|
||||
if (allChecked || selectedRarities.includes(rarity)) {
|
||||
count++;
|
||||
total += parseFloat(card.dataset.price) || 100;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('bulkSellCount').textContent = count;
|
||||
document.getElementById('bulkSellTotal').textContent = `${total.toFixed(0)} ₽`;
|
||||
}
|
||||
|
||||
async function executeBulkSell() {
|
||||
const selectedRarities = [];
|
||||
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => {
|
||||
if (cb.value && cb.value !== 'all') {
|
||||
selectedRarities.push(cb.value);
|
||||
}
|
||||
});
|
||||
|
||||
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
|
||||
|
||||
const idsToSell = [];
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
const rarity = card.dataset.rarity;
|
||||
if (allChecked || selectedRarities.includes(rarity)) {
|
||||
idsToSell.push(parseInt(card.dataset.inventoryId));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (idsToSell.length === 0) {
|
||||
alert('Выберите хотя бы одну редкость');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Продать ${idsToSell.length} предметов?`)) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(idsToSell));
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
await refreshBalance();
|
||||
// Remove all cards with animation
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
card.style.transition = 'all 0.3s';
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
});
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при продаже');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems() {
|
||||
const rarityFilter = document.getElementById('rarityFilter').value.toLowerCase();
|
||||
const typeFilter = document.getElementById('typeFilter').value.toLowerCase();
|
||||
const searchQuery = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
const items = document.querySelectorAll('.inventory-skin-card');
|
||||
let visibleCount = 0;
|
||||
|
||||
items.forEach(item => {
|
||||
const rarity = item.dataset.rarity.toLowerCase();
|
||||
const type = item.dataset.type.toLowerCase();
|
||||
const name = item.dataset.name;
|
||||
|
||||
let visible = true;
|
||||
|
||||
if (rarityFilter && rarity !== rarityFilter) visible = false;
|
||||
if (typeFilter && type !== typeFilter) visible = false;
|
||||
if (searchQuery && !name.includes(searchQuery)) visible = false;
|
||||
|
||||
item.style.display = visible ? 'block' : 'none';
|
||||
if (visible) visibleCount++;
|
||||
});
|
||||
|
||||
document.getElementById('totalItemsCount').textContent = visibleCount;
|
||||
|
||||
// Сбрасываем выбор при фильтрации
|
||||
selectedItems.clear();
|
||||
document.querySelectorAll('.item-select-checkbox').forEach(cb => cb.checked = false);
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(c => c.classList.remove('selected-for-sale'));
|
||||
document.getElementById('selectAllCheckbox').checked = false;
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('rarityFilter').value = '';
|
||||
document.getElementById('typeFilter').value = '';
|
||||
document.getElementById('searchInput').value = '';
|
||||
filterItems();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// Закрытие модального окна по клику вне
|
||||
document.getElementById('bulkSellModal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('bulk-sell-modal')) {
|
||||
closeBulkSellModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/safemode.js"></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>
|
||||
<script>
|
||||
// Живое обновление через WebSocket
|
||||
WS.on('inventory_update', () => refreshInventory());
|
||||
WS.on('item_sold', () => refreshInventory());
|
||||
WS.on('balance_update', () => { if (typeof refreshBalance === 'function') refreshBalance(); });
|
||||
|
||||
let refreshTimeout;
|
||||
function refreshInventory() {
|
||||
clearTimeout(refreshTimeout);
|
||||
refreshTimeout = setTimeout(() => {
|
||||
fetch('/web/api/inventory/items')
|
||||
.then(r => r.json())
|
||||
.then(items => rebuildInventoryGrid(items))
|
||||
.catch(() => {});
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function rebuildInventoryGrid(items) {
|
||||
const grid = document.getElementById('inventoryGrid');
|
||||
if (!grid) return;
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<div class="empty-state-large"><div class="empty-icon">📭</div><h3>Ваш инвентарь пуст</h3><p>Откройте кейсы, чтобы получить первые предметы!</p><a href="/cases" class="btn btn-primary">Открыть кейсы</a></div>';
|
||||
document.getElementById('selectAllCheckbox') && (document.getElementById('selectAllCheckbox').checked = false);
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
items.forEach(item => {
|
||||
const rarityClass = (item.rarity || 'unknown').toLowerCase().replace(/\s+/g, '-');
|
||||
const image = item.image_url || '/static/placeholder.png';
|
||||
const floatStr = item.float !== null && item.float !== undefined ? 'Float: ' + item.float.toFixed(6) : '';
|
||||
const wearStr = item.wear || '';
|
||||
const typeStr = item.type || '';
|
||||
html += `<div class="inventory-skin-card rarity-${rarityClass}"
|
||||
data-inventory-id="${item.id}"
|
||||
data-rarity="${item.rarity || ''}"
|
||||
data-type="${typeStr}"
|
||||
data-name="${(item.name || '').toLowerCase()}"
|
||||
data-price="${item.price_rub || 100}">
|
||||
<input type="checkbox" class="item-select-checkbox"
|
||||
onchange="onItemSelect(this)"
|
||||
data-id="${item.id}"
|
||||
data-price="${item.price_rub || 100}">
|
||||
<div class="skin-image-container">
|
||||
<img src="${image}" alt="${item.name}" class="skin-image" onerror="this.src='/static/placeholder.png'">
|
||||
</div>
|
||||
<div class="skin-info">
|
||||
<div class="skin-name" title="${item.name}">${item.name}</div>
|
||||
<div class="skin-details">
|
||||
<span class="rarity-${rarityClass}">${item.rarity || ''}</span>
|
||||
<span>${floatStr}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top:5px">
|
||||
<span>${typeStr}</span>
|
||||
<span>${wearStr}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top:5px;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>`;
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
document.getElementById('selectAllCheckbox') && (document.getElementById('selectAllCheckbox').checked = false);
|
||||
if (typeof filterItems === 'function') filterItems();
|
||||
}
|
||||
</script>
|
||||
{% include '_activity_sidebar.html' %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!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">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/register" class="btn btn-outline">Регистрация</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="auth-container">
|
||||
<div class="auth-card">
|
||||
<h2>Вход в аккаунт</h2>
|
||||
|
||||
<form id="loginForm" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя</label>
|
||||
<input type="text" id="username" name="username" required minlength="3">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" name="password" required minlength="4">
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="error-message" style="display: none;"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Войти</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
Нет аккаунта? <a href="/register">Зарегистрируйтесь</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
errorDiv.textContent = data.error || 'Ошибка входа';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
errorDiv.textContent = 'Ошибка соединения с сервером';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.2f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="profile-container">
|
||||
<div class="container">
|
||||
<div class="profile-header">
|
||||
{% if is_public %}
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
|
||||
<span style="background:var(--bg-card);padding:0.25rem 0.75rem;border-radius:20px;font-size:0.75rem;color:var(--text-secondary);border:1px solid var(--border-color);">👤 Публичный профиль</span>
|
||||
</div>
|
||||
<h1>👤 {{ profile_user.username }}</h1>
|
||||
<p class="profile-subtitle">Зарегистрирован {{ profile_user.created_at.strftime('%d.%m.%Y') }}</p>
|
||||
{% else %}
|
||||
<h1>👤 {{ user.username }}</h1>
|
||||
<p class="profile-subtitle">Добро пожаловать в ваш профиль</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ total_items }}</div>
|
||||
<div class="stat-label">Предметов в инвентаре</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ cases_opened }}</div>
|
||||
<div class="stat-label">Открыто кейсов</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ contracts_completed }}</div>
|
||||
<div class="stat-label">Выполнено контрактов</div>
|
||||
</div>
|
||||
{% if not is_public %}
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ "%.2f"|format(user.balance) }} ₽</div>
|
||||
<div class="stat-label">Баланс</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="profile-sections">
|
||||
<div class="section">
|
||||
<h2>🏆 Ценные предметы</h2>
|
||||
<div class="items-grid">
|
||||
{% set target_items = valuable_items %}
|
||||
{% for item in target_items %}
|
||||
<div class="item-card rarity-{{ item.rarity|lower|replace(' ', '-') }}">
|
||||
<div class="item-name">{{ item.market_hash_name }}</div>
|
||||
<div class="item-details">
|
||||
<span class="item-rarity">{{ item.rarity }}</span>
|
||||
<span class="item-float">Float: {{ "%.4f"|format(item.float_value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">У {% if is_public %}этого пользователя{% else %}вас{% endif %} пока нет предметов.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📦 Последние открытия</h2>
|
||||
<div class="history-list">
|
||||
{% for opening in recent_openings %}
|
||||
<div class="history-item">
|
||||
<div class="history-icon">📦</div>
|
||||
<div class="history-content">
|
||||
<div class="history-title">{{ opening.item_name }}</div>
|
||||
<div class="history-meta">
|
||||
<span class="rarity-{{ opening.rarity|lower|replace(' ', '-') }}">{{ opening.rarity }}</span>
|
||||
<span>Float: {{ "%.4f"|format(opening.float_value) }}</span>
|
||||
<span>{{ opening.opened_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Пока нет открытий</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🔄 Последние контракты</h2>
|
||||
<div class="history-list">
|
||||
{% for contract in recent_contracts %}
|
||||
<div class="history-item">
|
||||
<div class="history-icon">🔄</div>
|
||||
<div class="history-content">
|
||||
<div class="history-title">{{ contract.output_item_name }}</div>
|
||||
<div class="history-meta">
|
||||
<span>Float: {{ "%.4f"|format(contract.output_float) }}</span>
|
||||
<span>Шанс: {{ "%.2f"|format(contract.probability) }}%</span>
|
||||
<span>{{ contract.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Пока нет контрактов</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
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>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
{% if user and not is_public %}{% include '_activity_sidebar.html' %}{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!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">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="auth-container">
|
||||
<div class="auth-card">
|
||||
<h2>Регистрация</h2>
|
||||
|
||||
<form id="registerForm" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя</label>
|
||||
<input type="text" id="username" name="username" required minlength="3">
|
||||
<small>Минимум 3 символа</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" name="password" required minlength="4">
|
||||
<small>Минимум 4 символа</small>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="error-message" style="display: none;"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Зарегистрироваться</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
Уже есть аккаунт? <a href="/login">Войдите</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.getElementById('registerForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
errorDiv.textContent = data.error || 'Ошибка регистрации';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
errorDiv.textContent = 'Ошибка соединения с сервером';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,824 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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">
|
||||
</head>
|
||||
<body class="upgrade-page">
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link active">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link active">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="upgrade-container">
|
||||
<div class="container">
|
||||
<div class="upgrade-header">
|
||||
<h1>🆙 Апгрейд</h1>
|
||||
<p>Рискни предметом ради более дорогого</p>
|
||||
</div>
|
||||
|
||||
<div class="upgrade-layout">
|
||||
<!-- Inventory -->
|
||||
<div class="inventory-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<span>📦 Инвентарь</span>
|
||||
<span style="font-size: 0.65rem; color: #6b6b7a;">{{ inventory_items|length }}</span>
|
||||
</div>
|
||||
<div class="inv-filter-bar">
|
||||
<input type="text" class="inv-search-input" id="invSearch" placeholder="Поиск..." oninput="onInvSearch()">
|
||||
<div class="inv-sort-group">
|
||||
<button class="inv-sort-btn" onclick="setInvSort('default', this)">📋</button>
|
||||
<button class="inv-sort-btn" onclick="setInvSort('price_asc', this)">💰↑</button>
|
||||
<button class="inv-sort-btn" onclick="setInvSort('price_desc', this)">💰↓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inventory-list" id="inventoryList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Wheel -->
|
||||
<div class="wheel-panel" style="position: relative;">
|
||||
<div class="selected-items-display">
|
||||
<div class="selected-item-mini" id="selectedInputDisplay">
|
||||
<div class="item-frame">
|
||||
<img src="/static/placeholder.png" id="displayInputImage">
|
||||
</div>
|
||||
<div class="selected-item-mini-name" id="displayInputName">Не выбрано</div>
|
||||
<div class="selected-item-mini-price" id="displayInputPrice">—</div>
|
||||
</div>
|
||||
<div class="arrow-icon">➡</div>
|
||||
<div class="selected-item-mini" id="selectedTargetDisplay">
|
||||
<div class="item-frame">
|
||||
<img src="/static/placeholder.png" id="displayTargetImage">
|
||||
</div>
|
||||
<div class="selected-item-mini-name" id="displayTargetName">Не выбрано</div>
|
||||
<div class="selected-item-mini-price" id="displayTargetPrice">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wheel-container">
|
||||
<div class="wheel-outer-ring"></div>
|
||||
<div class="wheel" id="upgradeWheel" style="--probability: 0;"></div>
|
||||
<div class="wheel-ticks"></div>
|
||||
<div class="wheel-pointer-container" id="pointerContainer">
|
||||
<div class="wheel-pointer"></div>
|
||||
</div>
|
||||
<div class="wheel-center" id="wheelProbability">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="upgrade-actions">
|
||||
{% if user %}
|
||||
<button class="upgrade-btn" onclick="executeUpgrade()" id="upgradeBtn" disabled>
|
||||
🎲 Апгрейд
|
||||
</button>
|
||||
<button class="quick-btn" id="quickModeBtn" onclick="toggleQuickMode()" title="Быстрый режим">⚡</button>
|
||||
{% else %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;width:100%;justify-content:center;padding:1rem;">
|
||||
<span style="color:var(--text-secondary);">🔒 Войдите, чтобы совершать апгрейды</span>
|
||||
<a href="/login" class="btn btn-primary">Войти</a>
|
||||
<a href="/register" class="btn btn-outline">Регистрация</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Target -->
|
||||
<div class="target-panel">
|
||||
<div class="target-header">
|
||||
<div class="panel-title" style="margin-bottom: 0.4rem;">
|
||||
<span>🎯 Цель</span>
|
||||
</div>
|
||||
<input type="text" class="target-search-input" id="targetSearch" placeholder="Поиск..." oninput="searchTargetItems()">
|
||||
<div class="sort-controls">
|
||||
<button class="sort-btn" onclick="setTargetSort('closest', this)">🎯 Ближайшие</button>
|
||||
<button class="sort-btn" onclick="setTargetSort('price_desc', this)">💎 Дорогие</button>
|
||||
<button class="sort-btn" onclick="setTargetSort('price_asc', this)">💰 Дешёвые</button>
|
||||
<button class="sort-btn" onclick="setTargetSort('name', this)">📋 Имя</button>
|
||||
</div>
|
||||
<div class="price-filter-bar" id="priceFilterBar" style="display: none;">
|
||||
<span style="font-size:0.55rem;color:#4a4a5a;text-transform:uppercase;letter-spacing:0.04em;padding:0.2rem 0;margin-right:0.15rem;">Шанс</span>
|
||||
<button class="pf-btn" onclick="setPriceFilter('probhigh', this)">75%</button>
|
||||
<button class="pf-btn" onclick="setPriceFilter('probmid', this)">50%</button>
|
||||
<button class="pf-btn" onclick="setPriceFilter('probalow', this)">25%</button>
|
||||
<button class="pf-btn" onclick="setPriceFilter('proba10', this)">10%</button>
|
||||
<span style="font-size:0.55rem;color:#4a4a5a;text-transform:uppercase;letter-spacing:0.04em;padding:0.2rem 0;margin:0 0.15rem;">×</span>
|
||||
<button class="pf-btn" onclick="setPriceFilter('2', this)">2×</button>
|
||||
<button class="pf-btn" onclick="setPriceFilter('4', this)">4×</button>
|
||||
<button class="pf-btn" onclick="setPriceFilter('8', this)">8×</button>
|
||||
<button class="pf-btn" onclick="setPriceFilter('10', this)">10×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="target-list" id="targetList">
|
||||
<div class="empty-state">Выберите предмет</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<div class="history-section">
|
||||
<h3 class="history-title">📜 История <span class="history-count" id="historyCount">{{ recent_upgrades|length }}</span></h3>
|
||||
<div class="history-grid" id="historyGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// === Фразы для побед/поражений ===
|
||||
const PHRASE_PARTS = {
|
||||
win: {
|
||||
pre: ['🎉','🔥','⭐','🏆','💎','✨','🎯','👑','💫','🌟','🎰','💪','🥇','🎊','🏅'],
|
||||
verb: ['Победа','Джекпот','Успех','Красава','В дамках','Есть контакт','Повезло','Забрал','Выбил','Сорвал куш','Всё пучком','Лут','Топчик','Козырь','Фартануло'],
|
||||
noun: ['! 🔥','! 💎','! 🏆','! 👑','! ✨','! 💪','!','!','!'],
|
||||
},
|
||||
lose: {
|
||||
pre: ['😔','💔','😢','💀','😭','😤','😩','💸','😞','😓','🤡','💀','😅','🙃','🤷'],
|
||||
verb: ['Не повезло','Облом','Мимо','Пролёт','Не выпал','Не срослось','Пусто','Мимо кассы','В следующий раз','Не фортануло','Повезёт потом','Ноль','Минус','Увы','Печаль'],
|
||||
noun: [' 😔',' 💔','...',' 😢',' 😭',' 💸','...'],
|
||||
}
|
||||
};
|
||||
let usedPhraseKeys = new Set();
|
||||
function getRandomPhrase(type) {
|
||||
const parts = PHRASE_PARTS[type];
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const pre = parts.pre[Math.floor(Math.random() * parts.pre.length)];
|
||||
const verb = parts.verb[Math.floor(Math.random() * parts.verb.length)];
|
||||
const noun = parts.noun[Math.floor(Math.random() * parts.noun.length)];
|
||||
const key = pre + '|' + verb + '|' + noun;
|
||||
if (!usedPhraseKeys.has(key)) {
|
||||
usedPhraseKeys.add(key);
|
||||
return pre + ' ' + verb + noun;
|
||||
}
|
||||
}
|
||||
usedPhraseKeys.clear();
|
||||
return getRandomPhrase(type);
|
||||
}
|
||||
|
||||
let selectedInput = null;
|
||||
let selectedTarget = null;
|
||||
let inventoryItems = {{ inventory_items|tojson }};
|
||||
let allTargetItems = [];
|
||||
let currentSearchQuery = '';
|
||||
let currentSort = 'closest';
|
||||
let currentPriceFilter = null;
|
||||
let isSpinning = false;
|
||||
let quickMode = false;
|
||||
let invSearchQuery = '';
|
||||
let invSort = 'default';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadInitialTargets();
|
||||
renderHistory({{ recent_upgrades|tojson }});
|
||||
renderInventory();
|
||||
});
|
||||
|
||||
function renderInventory() {
|
||||
let items = [...inventoryItems];
|
||||
|
||||
if (invSearchQuery.length >= 1) {
|
||||
const q = invSearchQuery.toLowerCase();
|
||||
items = items.filter(i => i.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
if (invSort === 'price_asc') {
|
||||
items.sort((a, b) => a.price_rub - b.price_rub);
|
||||
} else if (invSort === 'price_desc') {
|
||||
items.sort((a, b) => b.price_rub - a.price_rub);
|
||||
}
|
||||
|
||||
const container = document.getElementById('inventoryList');
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state" style="padding:1rem;text-align:center;color:var(--text-secondary)">Ничего не найдено</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = items.map(item => `
|
||||
<div class="inventory-item-card ${selectedInput && selectedInput.inventory_id === item.id ? 'selected' : ''}"
|
||||
onclick="selectInputItem(${item.id})" data-id="${item.id}" data-price="${item.price_rub}">
|
||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="inventory-item-info">
|
||||
<div class="inventory-item-name" title="${item.name}">${item.name}</div>
|
||||
<div class="inventory-item-meta">
|
||||
<span>${item.rarity}</span>
|
||||
<span class="inventory-item-price">${Math.floor(item.price_rub).toLocaleString()} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadInitialTargets() {
|
||||
try {
|
||||
const response = await fetch(`/admin/api/items/search?q=&limit=600`);
|
||||
allTargetItems = await response.json();
|
||||
allTargetItems = allTargetItems.filter((item, index, self) =>
|
||||
index === self.findIndex(t => t.id === item.id)
|
||||
);
|
||||
currentSort = 'closest';
|
||||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
|
||||
applySortAndFilter();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function loadTargetsForInput(inputPrice) {
|
||||
try {
|
||||
// Поиск только по цене — больше выбора и приколюх
|
||||
const minP = Math.round(inputPrice * 1.05);
|
||||
const maxP = Math.round(inputPrice * 19);
|
||||
const resp = await fetch(`/admin/api/items/search?q=&min_price=${minP}&max_price=${maxP}&limit=600`);
|
||||
const items = await resp.json();
|
||||
allTargetItems = items.filter((item, index, self) =>
|
||||
index === self.findIndex(t => t.id === item.id)
|
||||
);
|
||||
currentSort = 'closest';
|
||||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
|
||||
applySortAndFilter();
|
||||
} catch (e) {
|
||||
console.error('Failed to load targets:', e);
|
||||
applySortAndFilter();
|
||||
}
|
||||
}
|
||||
|
||||
function selectInputItem(itemId) {
|
||||
if (isSpinning) return;
|
||||
const item = inventoryItems.find(i => i.id === itemId);
|
||||
if (!item) return;
|
||||
|
||||
selectedInput = {
|
||||
inventory_id: item.id,
|
||||
item_id: item.item_id,
|
||||
name: item.name,
|
||||
price: item.price_rub,
|
||||
image_url: item.image_url,
|
||||
rarity: item.rarity
|
||||
};
|
||||
|
||||
renderInventory();
|
||||
document.getElementById('displayInputImage').src = item.image_url || '/static/placeholder.png';
|
||||
document.getElementById('displayInputName').textContent = item.name.substring(0, 25);
|
||||
document.getElementById('displayInputPrice').textContent = `${Math.floor(item.price_rub).toLocaleString()} ₽`;
|
||||
document.getElementById('selectedInputDisplay').classList.add('selected');
|
||||
|
||||
document.getElementById('priceFilterBar').style.display = 'flex';
|
||||
currentPriceFilter = null;
|
||||
document.querySelectorAll('.pf-btn').forEach(b => b.classList.remove('active'));
|
||||
currentSort = 'closest';
|
||||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
|
||||
|
||||
loadTargetsForInput(item.price_rub);
|
||||
updateProbability();
|
||||
}
|
||||
|
||||
function applySortAndFilter() {
|
||||
if (!allTargetItems.length) return;
|
||||
|
||||
let filtered = [...allTargetItems];
|
||||
|
||||
if (currentSearchQuery.length >= 2) {
|
||||
filtered = filtered.filter(item =>
|
||||
item.name.toLowerCase().includes(currentSearchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedInput) {
|
||||
const inputPrice = selectedInput.price;
|
||||
filtered = filtered.filter(item => {
|
||||
const price = item.price_rub || 100;
|
||||
if (price <= inputPrice) return false;
|
||||
const maxPrice = inputPrice * 19;
|
||||
return price <= maxPrice;
|
||||
});
|
||||
|
||||
if (currentPriceFilter) {
|
||||
filtered = filtered.filter(item => {
|
||||
const price = item.price_rub || 100;
|
||||
const ratio = price / inputPrice;
|
||||
const prob = (inputPrice / price) * 100;
|
||||
switch (currentPriceFilter) {
|
||||
case '2': return ratio >= 1.8 && ratio <= 2.5;
|
||||
case '4': return ratio >= 3.5 && ratio <= 5;
|
||||
case '8': return ratio >= 7 && ratio <= 10;
|
||||
case '10': return ratio >= 8.5 && ratio <= 12;
|
||||
case 'probalow': return prob >= 20 && prob <= 30;
|
||||
case 'probmid': return prob >= 45 && prob <= 55;
|
||||
case 'probhigh': return prob >= 70 && prob <= 75;
|
||||
case 'proba10': return prob >= 8 && prob <= 12;
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSort === 'closest' && selectedInput) {
|
||||
const inputPrice = selectedInput.price;
|
||||
filtered.sort((a, b) => {
|
||||
const pa = (inputPrice / (a.price_rub || 100)) * 100;
|
||||
const pb = (inputPrice / (b.price_rub || 100)) * 100;
|
||||
const da = Math.abs(Math.min(75, pa) - 75);
|
||||
const db = Math.abs(Math.min(75, pb) - 75);
|
||||
if (da !== db) return da - db;
|
||||
return (a.price_rub || 0) - (b.price_rub || 0);
|
||||
});
|
||||
} else if (currentSort === 'price_asc') {
|
||||
filtered.sort((a, b) => (a.price_rub || 0) - (b.price_rub || 0));
|
||||
} else if (currentSort === 'price_desc') {
|
||||
filtered.sort((a, b) => (b.price_rub || 0) - (a.price_rub || 0));
|
||||
} else {
|
||||
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
displayTargetItems(filtered.slice(0, 120));
|
||||
}
|
||||
|
||||
function setTargetSort(sort, btn) {
|
||||
currentSort = sort;
|
||||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
applySortAndFilter();
|
||||
}
|
||||
|
||||
function setPriceFilter(mult, btn) {
|
||||
currentPriceFilter = mult === currentPriceFilter ? null : mult;
|
||||
document.querySelectorAll('.pf-btn').forEach(b => b.classList.remove('active'));
|
||||
if (currentPriceFilter && btn) btn.classList.add('active');
|
||||
applySortAndFilter();
|
||||
}
|
||||
|
||||
function onInvSearch() {
|
||||
invSearchQuery = document.getElementById('invSearch').value;
|
||||
renderInventory();
|
||||
}
|
||||
|
||||
function setInvSort(sort, btn) {
|
||||
invSort = sort;
|
||||
document.querySelectorAll('.inv-sort-btn').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
renderInventory();
|
||||
}
|
||||
|
||||
function toggleQuickMode() {
|
||||
quickMode = !quickMode;
|
||||
document.getElementById('quickModeBtn').classList.toggle('active', quickMode);
|
||||
}
|
||||
|
||||
async function searchTargetItems() {
|
||||
const query = document.getElementById('targetSearch').value;
|
||||
currentSearchQuery = query;
|
||||
const container = document.getElementById('targetList');
|
||||
|
||||
if (query.length < 2) {
|
||||
applySortAndFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<div class="empty-state">🔍 Поиск…</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/items/search?q=${encodeURIComponent(query)}&limit=600`);
|
||||
const items = await response.json();
|
||||
allTargetItems = items.filter((item, index, self) =>
|
||||
index === self.findIndex(t => t.id === item.id)
|
||||
);
|
||||
applySortAndFilter();
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="empty-state">Ошибка</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayTargetItems(items) {
|
||||
const container = document.getElementById('targetList');
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">Ничего не найдено</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = items.map(item => {
|
||||
const price = item.price_rub || 100;
|
||||
const imageUrl = item.image_url || '/static/placeholder.png';
|
||||
|
||||
let isDisabled = false;
|
||||
let disabledReason = '';
|
||||
if (selectedInput) {
|
||||
const minPrice = selectedInput.price * 0.5;
|
||||
const maxPrice = selectedInput.price * 19;
|
||||
if (price < minPrice) {
|
||||
isDisabled = true;
|
||||
disabledReason = 'Слишком дешёвый';
|
||||
} else if (price > maxPrice) {
|
||||
isDisabled = true;
|
||||
disabledReason = 'Слишком дорогой';
|
||||
}
|
||||
}
|
||||
|
||||
const safeName = item.name.replace(/'/g, "\\'");
|
||||
const safeUrl = imageUrl.replace(/'/g, "\\'");
|
||||
|
||||
return `
|
||||
<div class="target-item-card ${isDisabled ? 'disabled' : ''}"
|
||||
onclick="selectTargetItem(${item.id}, '${safeName}', ${price}, '${safeUrl}', '${item.rarity || 'Unknown'}')"
|
||||
data-price="${price}"
|
||||
title="${isDisabled ? disabledReason : ''}">
|
||||
<img src="${imageUrl}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="target-item-info">
|
||||
<div class="target-item-name" title="${item.name}">${item.name}</div>
|
||||
<div class="target-item-meta">
|
||||
<span>${item.rarity || ''}</span>
|
||||
<span class="target-item-price">${price.toLocaleString()} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (selectedTarget) {
|
||||
document.querySelectorAll('.target-item-card').forEach(card => {
|
||||
if (card.onclick && card.onclick.toString().includes(`selectTargetItem(${selectedTarget.item_id}`)) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectTargetItem(id, name, price, imageUrl, rarity) {
|
||||
if (isSpinning) return;
|
||||
const card = event.target.closest('.target-item-card');
|
||||
if (card.classList.contains('disabled')) {
|
||||
alert('Этот предмет не подходит!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.target-item-card').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
|
||||
selectedTarget = {
|
||||
item_id: id,
|
||||
name: name,
|
||||
price: price,
|
||||
image_url: imageUrl,
|
||||
rarity: rarity
|
||||
};
|
||||
|
||||
document.getElementById('displayTargetImage').src = imageUrl || '/static/placeholder.png';
|
||||
document.getElementById('displayTargetName').textContent = name.substring(0, 25);
|
||||
document.getElementById('displayTargetPrice').textContent = `${price.toLocaleString()} ₽`;
|
||||
document.getElementById('selectedTargetDisplay').classList.add('selected');
|
||||
|
||||
updateProbability();
|
||||
}
|
||||
|
||||
async function updateProbability() {
|
||||
if (!selectedInput || !selectedTarget) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('input_inventory_id', selectedInput.inventory_id);
|
||||
formData.append('target_item_id', selectedTarget.item_id);
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/upgrade/calculate', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const prob = data.adjusted_probability.toFixed(1);
|
||||
document.getElementById('wheelProbability').textContent = `${prob}%`;
|
||||
|
||||
const wheel = document.getElementById('upgradeWheel');
|
||||
wheel.style.setProperty('--probability', prob);
|
||||
|
||||
document.getElementById('upgradeBtn').disabled = false;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function sparkles(container) {
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'sparkle';
|
||||
s.style.left = Math.random() * 100 + '%';
|
||||
s.style.top = Math.random() * 100 + '%';
|
||||
s.style.setProperty('--dx', (Math.random() - 0.5) * 140 + 'px');
|
||||
s.style.setProperty('--dy', -(Math.random() * 120 + 50) + 'px');
|
||||
s.style.animationDelay = Math.random() * 0.6 + 's';
|
||||
s.style.width = (2 + Math.random() * 5) + 'px';
|
||||
s.style.height = s.style.width;
|
||||
s.style.background = ['#f59e0b', '#10b981', '#ffd700', '#fff', '#ff6b6b'][Math.floor(Math.random() * 5)];
|
||||
container.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBalance() {
|
||||
try {
|
||||
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()} ₽`;
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function refreshHistory() {
|
||||
try {
|
||||
const r = await fetch('/web/api/upgrade/history');
|
||||
const d = await r.json();
|
||||
if (d.success) renderHistory(d.history);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function renderHistory(items) {
|
||||
const grid = document.getElementById('historyGrid');
|
||||
const count = document.getElementById('historyCount');
|
||||
if (count) count.textContent = items.length;
|
||||
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1;text-align:center;padding:1rem;color:var(--text-secondary)">История пуста</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = items.map(u => `
|
||||
<div class="history-card ${u.success ? 'success' : 'fail'}">
|
||||
${u.success
|
||||
? `<img src="${u.input_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy">
|
||||
<div class="history-info">
|
||||
<div class="history-names">${(u.input_item_name || '').substring(0, 12)}… → ${(u.target_item_name || '').substring(0, 12)}…</div>
|
||||
<div class="history-meta">
|
||||
<span>${u.probability}%</span>
|
||||
<span class="history-result success">✅</span>
|
||||
</div>
|
||||
</div>
|
||||
<img src="${u.target_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy" style="width:32px;height:28px;">`
|
||||
: `<img src="${u.target_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy" style="opacity:0.5;">
|
||||
<div class="history-info">
|
||||
<div class="history-names">${(u.input_item_name || '').substring(0, 12)}… → ${(u.target_item_name || '').substring(0, 12)}…</div>
|
||||
<div class="history-meta">
|
||||
<span>${u.probability}%</span>
|
||||
<span class="history-result fail">❌</span>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function executeUpgrade() {
|
||||
if (!selectedInput || !selectedTarget || isSpinning) return;
|
||||
|
||||
// Lock the chosen items at spin start
|
||||
const lockedInputId = selectedInput.inventory_id;
|
||||
const lockedTargetId = selectedTarget.item_id;
|
||||
const lockedPrice = selectedInput.price;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('input_inventory_id', lockedInputId);
|
||||
formData.append('target_item_id', lockedTargetId);
|
||||
formData.append('quick_mode', quickMode ? 'true' : 'false');
|
||||
|
||||
const btn = document.getElementById('upgradeBtn');
|
||||
|
||||
isSpinning = true;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '🎰 Крутим…';
|
||||
|
||||
const pointer = document.getElementById('pointerContainer');
|
||||
pointer.style.transition = 'none';
|
||||
pointer.style.transform = 'rotate(0deg)';
|
||||
void pointer.offsetHeight;
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/upgrade/execute', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (typeof handleAchievements === 'function') handleAchievements(data);
|
||||
const spinDuration = quickMode ? 4 : 12;
|
||||
pointer.style.transition = `transform ${spinDuration}s cubic-bezier(0.4, 0.0, 0.15, 1.0)`;
|
||||
pointer.style.transform = `rotate(${data.final_angle}deg)`;
|
||||
|
||||
// Тики колеса: быстрая фаза каждые 15°, медленная (последние 90°) — 30°
|
||||
let tickLastDeg = 0, tickCumulative = 0, tickNextThreshold = 15;
|
||||
let tickLastTickCumulative = 0;
|
||||
let tickControlPlayed = false;
|
||||
const tickSlowStart = data.final_angle - 90;
|
||||
const tickStartTime = performance.now();
|
||||
const tickMaxDuration = spinDuration * 1000 + 500;
|
||||
let tickRAF = null;
|
||||
function tickLoop() {
|
||||
const elapsed = performance.now() - tickStartTime;
|
||||
if (elapsed > tickMaxDuration) return;
|
||||
const style = window.getComputedStyle(pointer);
|
||||
let deg = 0;
|
||||
const tf = style.transform;
|
||||
if (tf && tf !== 'none') {
|
||||
const m = tf.match(/matrix\(([^)]+)\)/);
|
||||
if (m) {
|
||||
const v = m[1].split(', ').map(Number);
|
||||
deg = Math.atan2(v[1], v[0]) * (180 / Math.PI);
|
||||
} else {
|
||||
const m2 = tf.match(/rotate\(([-\d.]+)deg\)/);
|
||||
if (m2) deg = parseFloat(m2[1]);
|
||||
}
|
||||
}
|
||||
let delta = deg - tickLastDeg;
|
||||
if (delta > 180) delta -= 360;
|
||||
if (delta < -180) delta += 360;
|
||||
tickCumulative += delta;
|
||||
tickLastDeg = deg;
|
||||
if (tickCumulative >= tickNextThreshold) {
|
||||
SoundManager.wheelSpin();
|
||||
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) {
|
||||
SoundManager.wheelSpin();
|
||||
tickControlPlayed = true;
|
||||
}
|
||||
tickRAF = requestAnimationFrame(tickLoop);
|
||||
}
|
||||
tickRAF = requestAnimationFrame(tickLoop);
|
||||
|
||||
let resultShown = false;
|
||||
const onTransitionEnd = () => {
|
||||
pointer.removeEventListener('transitionend', onTransitionEnd);
|
||||
if (tickRAF) cancelAnimationFrame(tickRAF);
|
||||
showResult();
|
||||
};
|
||||
pointer.addEventListener('transitionend', onTransitionEnd);
|
||||
|
||||
const showResult = () => {
|
||||
if (resultShown) return;
|
||||
resultShown = true;
|
||||
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);
|
||||
document.getElementById('displayTargetPrice').textContent = `${Math.floor(data.received_item.price).toLocaleString()} ₽`;
|
||||
document.getElementById('selectedTargetDisplay').style.borderColor = '#10b981';
|
||||
document.getElementById('selectedTargetDisplay').style.boxShadow = '0 0 20px rgba(16,185,129,0.3)';
|
||||
showToast(`${getRandomPhrase('win')} ${data.received_item.name}`, 'success');
|
||||
SoundManager.wheelWin();
|
||||
} else {
|
||||
document.getElementById('selectedInputDisplay').style.borderColor = '#ef4444';
|
||||
document.getElementById('selectedInputDisplay').style.boxShadow = '0 0 20px rgba(239,68,68,0.3)';
|
||||
showToast(getRandomPhrase('lose'), 'fail');
|
||||
SoundManager.wheelLose();
|
||||
}
|
||||
|
||||
refreshBalance();
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('selectedInputDisplay').style.borderColor = '';
|
||||
document.getElementById('selectedInputDisplay').style.boxShadow = '';
|
||||
document.getElementById('selectedTargetDisplay').style.borderColor = '';
|
||||
document.getElementById('selectedTargetDisplay').style.boxShadow = '';
|
||||
|
||||
inventoryItems = inventoryItems.filter(i => i.id !== lockedInputId);
|
||||
selectedInput = null;
|
||||
selectedTarget = null;
|
||||
document.getElementById('selectedInputDisplay').classList.remove('selected');
|
||||
document.getElementById('selectedTargetDisplay').classList.remove('selected');
|
||||
document.getElementById('displayInputImage').src = '/static/placeholder.png';
|
||||
document.getElementById('displayInputName').textContent = 'Не выбрано';
|
||||
document.getElementById('displayInputPrice').textContent = '—';
|
||||
document.getElementById('displayTargetImage').src = '/static/placeholder.png';
|
||||
document.getElementById('displayTargetName').textContent = 'Не выбрано';
|
||||
document.getElementById('displayTargetPrice').textContent = '—';
|
||||
document.getElementById('wheelProbability').textContent = '0%';
|
||||
document.getElementById('upgradeWheel').style.setProperty('--probability', '0');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '🎲 Апгрейд';
|
||||
isSpinning = false;
|
||||
refreshInventoryAjax();
|
||||
refreshHistory();
|
||||
pointer.style.transition = 'none';
|
||||
pointer.style.transform = 'rotate(0deg)';
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Запасной таймер на случай если transitionend не сработает
|
||||
setTimeout(showResult, spinDuration * 1000 + 300);
|
||||
} else {
|
||||
alert(data.error || 'Ошибка');
|
||||
isSpinning = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🎲 Апгрейд';
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка соединения');
|
||||
isSpinning = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🎲 Апгрейд';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshInventoryAjax() {
|
||||
try {
|
||||
const resp = await fetch('/web/api/inventory/items');
|
||||
const serverItems = await resp.json();
|
||||
// Enrich with price/image from server data
|
||||
inventoryItems = serverItems.map(item => {
|
||||
const img = item.image_url || (item.item_id ? `/static/placeholder.png` : '/static/placeholder.png');
|
||||
return {
|
||||
id: item.id,
|
||||
item_id: item.item_id,
|
||||
name: item.name,
|
||||
rarity: item.rarity,
|
||||
price_rub: item.price || item.price_rub || 100,
|
||||
image_url: img,
|
||||
wear: item.wear || '',
|
||||
float: item.float || item.float_value || 0
|
||||
};
|
||||
});
|
||||
renderInventory();
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh inventory:', e);
|
||||
renderInventory();
|
||||
}
|
||||
}
|
||||
|
||||
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/safemode.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
</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>
|
||||
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import random
|
||||
import math
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from database import User, UpgradeRPU, Upgrade
|
||||
|
||||
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()
|
||||
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
|
||||
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))
|
||||
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)
|
||||
|
||||
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
|
||||
else:
|
||||
rpu.total_spent_value += input_price
|
||||
|
||||
if rpu.auto_adjust and rpu.total_attempts >= 15:
|
||||
rate = rpu.total_success / rpu.total_attempts
|
||||
if rate < 0.2:
|
||||
rpu.upgrade_multiplier = min(2.0, rpu.upgrade_multiplier + 0.1)
|
||||
elif rate > 0.6:
|
||||
rpu.upgrade_multiplier = max(0.5, rpu.upgrade_multiplier - 0.1)
|
||||
|
||||
db.commit()
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import asyncio
|
||||
import random
|
||||
import math
|
||||
from typing import Set, Dict, Optional
|
||||
from fastapi import WebSocket
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
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, sockets in dead.items():
|
||||
self.active_connections[uid] -= sockets
|
||||
if not self.active_connections[uid]:
|
||||
del self.active_connections[uid]
|
||||
|
||||
async def broadcast_except(self, exclude_user_id: int, message: dict):
|
||||
async with self._lock:
|
||||
for uid, sockets in self.active_connections.items():
|
||||
if uid == exclude_user_id:
|
||||
continue
|
||||
for ws in sockets:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def online_count(self) -> int:
|
||||
"""Количество уникальных пользователей онлайн"""
|
||||
return len(self.active_connections)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
# ─── Crash Game State ────────────────────────────────────────────────────────
|
||||
|
||||
class CrashGameState:
|
||||
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
|
||||
self.round = 0
|
||||
self.history: list = []
|
||||
self._start_time = 0.0
|
||||
self._elapsed = 0.0
|
||||
|
||||
def generate_crash_point(self) -> float:
|
||||
"""True CS2-style provably fair crash point using exponential distribution"""
|
||||
# Используем распределение: чем выше множитель, тем меньше шанс
|
||||
r = random.random()
|
||||
# Формула: crash_point = 1 / (1 - r) с ограничением
|
||||
# Это даёт экспоненциальное распределение
|
||||
crash_point = 1.0 / (1.0 - r * 0.99)
|
||||
# В редких случаях может быть очень высоким (>1000), ограничиваем
|
||||
crash_point = min(crash_point, 1000.0)
|
||||
return round(crash_point, 2)
|
||||
|
||||
def get_multiplier_at_time(self, elapsed_seconds: float) -> float:
|
||||
"""Экспоненциальный рост множителя: e^(t * speed)"""
|
||||
speed = 0.08 # скорость роста
|
||||
return round(math.exp(elapsed_seconds * speed), 2)
|
||||
|
||||
def get_time_for_multiplier(self, mult: float) -> float:
|
||||
"""Сколько времени нужно для достижения множителя"""
|
||||
return math.log(mult) / 0.08
|
||||
|
||||
def calculate_payout(self, bet_amount: float, cashout_at: float) -> float:
|
||||
return round(bet_amount * cashout_at, 2)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"round": self.round,
|
||||
"current_multiplier": self.current_multiplier,
|
||||
"crash_point": self.crash_point,
|
||||
"timer": self.timer,
|
||||
"bet_count": len(self.bets),
|
||||
"total_bets": sum(self.bets.values()),
|
||||
"cashed_out": {str(k): v for k, v in self.cashed_out.items()},
|
||||
"history": self.history[-20:],
|
||||
}
|
||||
|
||||
|
||||
crash_game = CrashGameState()
|
||||
Reference in New Issue
Block a user