chore: initial commit — CS2 Simulator

This commit is contained in:
2026-07-05 10:40:00 +00:00
commit c268557997
40 changed files with 824236 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.pyo
.DS_Store
+417
View File
@@ -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
+802
View File
@@ -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} изменен"
})
+129
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+492
View File
@@ -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()
+63
View File
@@ -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"
]
}
]
+192
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+430000
View File
File diff suppressed because it is too large Load Diff
+4489
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -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);
}
+347
View File
@@ -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();
}
+185
View File
@@ -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());
+93
View File
@@ -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

+292
View File
@@ -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>
+323
View File
@@ -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>
+245
View File
@@ -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>
+361
View File
@@ -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="/">&larr; <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>
+361
View File
@@ -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,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ci-info"><div>${item.name}</div><div class="ci-rarity">${item.rarity} • ${item.price_rub||0} ₽</div></div>
</div>`;
});
html += '</div>';
const existing = document.getElementById('caseItemSearchResults');
if (!existing) {
const d = document.createElement('div'); d.id = 'caseItemSearchResults';
document.getElementById('caseItemSearch').parentNode.appendChild(d);
}
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,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div style="margin-top:0.2rem;line-height:1.15">${item.name}</div>
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35)">${item.rarity}</div>
</div>
`).join('');
}
function addCaseItemFromAll(item) {
addCaseItem(item);
closeModal('allItemsModal');
}
// ─── Mainpage Sections ───────────────────────────────────────────────────────
function openAddSectionModal() {
document.getElementById('newSectionName').value = '';
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 %}
+57
View File
@@ -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 %}
+294
View File
@@ -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">&larr; Назад</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 %}
+72
View File
@@ -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
+259
View File
@@ -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>
+738
View File
@@ -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>
+518
View File
@@ -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>
+58
View File
@@ -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>
+802
View File
@@ -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>
+75
View File
@@ -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>
+159
View File
@@ -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>
+77
View File
@@ -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>
+824
View File
@@ -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
View File
@@ -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()
+129
View File
@@ -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()