From 17da0b6d7921ec83aa958285a0191997c8098010 Mon Sep 17 00:00:00 2001 From: sasheg Date: Sat, 25 Jul 2026 13:31:40 +0000 Subject: [PATCH] PostgreSQL + async refactoring, WebSocket server, Redis caching, minified assets --- .gitignore | 1 + achievements.py | 3120 +++++++------------------------- auth.py | 124 +- cache.py | 67 + database.py | 424 ++--- frontend.py | 96 +- migrate_db.py | 85 + requirements.txt | 4 +- rpu.py | 656 +++---- start.sh | 38 + static/css/style.min.css | 1 + static/js/app.min.js | 14 + static/js/notifications.min.js | 19 + static/js/sounds.min.js | 4 + static/js/websocket.min.js | 4 + templates/achievements.html | 8 +- templates/admin/base.html | 4 +- templates/case_detail.html | 10 +- templates/cases.html | 8 +- templates/contracts.html | 10 +- templates/crash.html | 8 +- templates/index.html | 4 +- templates/login.html | 2 +- templates/profile.html | 8 +- templates/register.html | 2 +- templates/upgrade.html | 10 +- upgrade.py | 99 +- ws_server.py | 254 +++ 28 files changed, 1840 insertions(+), 3244 deletions(-) create mode 100644 cache.py create mode 100644 migrate_db.py create mode 100755 start.sh create mode 100644 static/css/style.min.css create mode 100644 static/js/app.min.js create mode 100644 static/js/notifications.min.js create mode 100644 static/js/sounds.min.js create mode 100644 static/js/websocket.min.js create mode 100644 ws_server.py diff --git a/.gitignore b/.gitignore index 461e8e9..1f1ba53 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ *.pyc .DS_Store +venv/ diff --git a/achievements.py b/achievements.py index 401259e..c46bbe0 100644 --- a/achievements.py +++ b/achievements.py @@ -1,15 +1,14 @@ from typing import Optional -from sqlalchemy.orm import Session -from database import Achievement, UserAchievement, ActivityFeed +from sqlalchemy import select, func as sa_func +from sqlalchemy.ext.asyncio import AsyncSession +from database import Achievement, UserAchievement, ActivityFeed, InventoryItem, CaseOpening, Contract from datetime import datetime import json import math -# Кэш коллекций — строится один раз _COLLECTION_CACHE = None def _build_collection_cache(): - """Строит кэш: все базовые имена по коллекциям.""" global _COLLECTION_CACHE if _COLLECTION_CACHE is not None: return @@ -27,7 +26,6 @@ def _build_collection_cache(): if base_set: coll_base_names[coll_name] = base_set coll_item_ids[coll_name] = set(clean_ids) - # Кэшируем custom-флаг на каждую коллекцию coll_is_custom = {} for coll_name, item_ids in COLLECTIONS_INDEX.items(): is_custom = False @@ -36,1812 +34,654 @@ def _build_collection_cache(): is_custom = True break coll_is_custom[coll_name] = is_custom - _COLLECTION_CACHE = { - "base_names": coll_base_names, - "item_ids": coll_item_ids, - "is_custom": coll_is_custom, - } + _COLLECTION_CACHE = {"base_names": coll_base_names, "item_ids": coll_item_ids, "is_custom": coll_is_custom} -# ─── Определения достижений ───────────────────────────────────────────────── +# ─── Ачивки ───────────────────────────────────────────────────────────────── 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_milestone", - "requirement_value": 100000, - "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_milestone", - "requirement_value": 5, - "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, - }, - - # ═══════════════════════════════════════════════════════════════════════════ - # 🆙 АПГРЕЙД (28 новых) - # ═══════════════════════════════════════════════════════════════════════════ - { - "name": "upgrade_5", - "title": "Начинающий", - "description": "Сыграй 5 апгрейдов", - "icon": "🆙", - "category": "upgrade", - "requirement_type": "upgrade_attempts", - "requirement_value": 5, - "reward_amount": 200, - "sort_order": 20, - }, - { - "name": "upgrade_25", - "title": "Энтузиаст", - "description": "Сыграй 25 апгрейдов", - "icon": "🆙", - "category": "upgrade", - "requirement_type": "upgrade_attempts", - "requirement_value": 25, - "reward_amount": 500, - "sort_order": 21, - }, - { - "name": "upgrade_50", - "title": "Мастер апгрейда", - "description": "Сыграй 50 апгрейдов", - "icon": "⬆️", - "category": "upgrade", - "requirement_type": "upgrade_attempts", - "requirement_value": 50, - "reward_amount": 1500, - "sort_order": 22, - }, - { - "name": "upgrade_100", - "title": "Профи", - "description": "Сыграй 100 апгрейдов", - "icon": "⬆️", - "category": "upgrade", - "requirement_type": "upgrade_attempts", - "requirement_value": 100, - "reward_amount": 3000, - "sort_order": 23, - }, - { - "name": "upgrade_250", - "title": "Ветеран", - "description": "Сыграй 250 апгрейдов", - "icon": "⚡", - "category": "upgrade", - "requirement_type": "upgrade_attempts", - "requirement_value": 250, - "reward_amount": 7500, - "sort_order": 24, - }, - { - "name": "upgrade_500", - "title": "Легенда", - "description": "Сыграй 500 апгрейдов", - "icon": "⚡", - "category": "upgrade", - "requirement_type": "upgrade_attempts", - "requirement_value": 500, - "reward_amount": 15000, - "sort_order": 25, - }, - { - "name": "upgrade_1000", - "title": "Бессмертный", - "description": "Сыграй 1000 апгрейдов", - "icon": "🏆", - "category": "upgrade", - "requirement_type": "upgrade_attempts", - "requirement_value": 1000, - "reward_amount": 30000, - "sort_order": 26, - }, - { - "name": "upgrade_wins_5", - "title": "Удачный старт", - "description": "Выиграй 5 апгрейдов", - "icon": "🍀", - "category": "upgrade", - "requirement_type": "upgrade_wins", - "requirement_value": 5, - "reward_amount": 400, - "sort_order": 27, - }, - { - "name": "upgrade_wins_25", - "title": "Счастливчик", - "description": "Выиграй 25 апгрейдов", - "icon": "🍀", - "category": "upgrade", - "requirement_type": "upgrade_wins", - "requirement_value": 25, - "reward_amount": 2000, - "sort_order": 28, - }, - { - "name": "upgrade_wins_50", - "title": "Баловень судьбы", - "description": "Выиграй 50 апгрейдов", - "icon": "✨", - "category": "upgrade", - "requirement_type": "upgrade_wins", - "requirement_value": 50, - "reward_amount": 5000, - "sort_order": 29, - }, - { - "name": "upgrade_wins_100", - "title": "Везучий", - "description": "Выиграй 100 апгрейдов", - "icon": "✨", - "category": "upgrade", - "requirement_type": "upgrade_wins", - "requirement_value": 100, - "reward_amount": 10000, - "sort_order": 30, - }, - { - "name": "upgrade_wins_250", - "title": "Невезучий... или нет?", - "description": "Выиграй 250 апгрейдов", - "icon": "🌟", - "category": "upgrade", - "requirement_type": "upgrade_wins", - "requirement_value": 250, - "reward_amount": 25000, - "sort_order": 31, - }, - { - "name": "upgrade_high_risk_50k", - "title": "Ол-ин", - "description": "Поставь предмет дороже 50 000 ₽ в апгрейд", - "icon": "💀", - "category": "upgrade", - "requirement_type": "upgrade_high_risk_item", - "requirement_value": 50000, - "reward_amount": 2000, - "sort_order": 32, - }, - { - "name": "upgrade_high_risk_500k", - "title": "Сорвиголова", - "description": "Поставь предмет дороже 500 000 ₽ в апгрейд", - "icon": "💀", - "category": "upgrade", - "requirement_type": "upgrade_high_risk_item", - "requirement_value": 500000, - "reward_amount": 15000, - "sort_order": 33, - }, - { - "name": "upgrade_high_risk_10m", - "title": "Безумец", - "description": "Поставь предмет дороже 10 000 000 ₽ в апгрейд", - "icon": "☠️", - "category": "upgrade", - "requirement_type": "upgrade_high_risk_item", - "requirement_value": 10000000, - "reward_amount": 50000, - "sort_order": 34, - }, - { - "name": "upgrade_2000", - "title": "Риск — дело благородное", - "description": "Сыграй 2000 апгрейдов", - "icon": "🎲", - "category": "upgrade", - "requirement_type": "upgrade_attempts", - "requirement_value": 2000, - "reward_amount": 5000, - "sort_order": 35, - }, - { - "name": "upgrade_consecutive_wins_3", - "title": "Хет-трик", - "description": "Выиграй 3 апгрейда подряд", - "icon": "🔥", - "category": "upgrade", - "requirement_type": "upgrade_consecutive_wins", - "requirement_value": 3, - "reward_amount": 1000, - "sort_order": 36, - }, - { - "name": "upgrade_consecutive_wins_5", - "title": "Победная серия", - "description": "Выиграй 5 апгрейдов подряд", - "icon": "🔥", - "category": "upgrade", - "requirement_type": "upgrade_consecutive_wins", - "requirement_value": 5, - "reward_amount": 3000, - "sort_order": 37, - }, - { - "name": "upgrade_consecutive_wins_10", - "title": "Неостановимый", - "description": "Выиграй 10 апгрейдов подряд", - "icon": "🌋", - "category": "upgrade", - "requirement_type": "upgrade_consecutive_wins", - "requirement_value": 10, - "reward_amount": 10000, - "sort_order": 38, - }, - { - "name": "upgrade_consecutive_losses_5", - "title": "Неудачник", - "description": "Проиграй 5 апгрейдов подряд", - "icon": "😅", - "category": "upgrade", - "requirement_type": "upgrade_consecutive_losses", - "requirement_value": 5, - "reward_amount": 500, - "sort_order": 39, - }, - { - "name": "upgrade_consecutive_losses_10", - "title": "Жертва системы", - "description": "Проиграй 10 апгрейдов подряд", - "icon": "😭", - "category": "upgrade", - "requirement_type": "upgrade_consecutive_losses", - "requirement_value": 10, - "reward_amount": 1500, - "sort_order": 40, - }, - { - "name": "upgrade_from_nothing", - "title": "Из грязи в князи", - "description": "Выиграй апгрейд с предметом дешевле 1000 ₽", - "icon": "🪴", - "category": "upgrade", - "requirement_type": "upgrade_low_value_win", - "requirement_value": 1000, - "reward_amount": 2000, - "sort_order": 41, - }, - { - "name": "upgrade_miracle", - "title": "Чудо", - "description": "Выиграй апгрейд с шансом менее 10%", - "icon": "🙏", - "category": "upgrade", - "requirement_type": "upgrade_low_prob_win", - "requirement_value": 10, - "reward_amount": 5000, - "sort_order": 42, - }, - { - "name": "upgrade_miracle_5", - "title": "Божественное вмешательство", - "description": "Выиграй 5 апгрейдов с шансом менее 10%", - "icon": "😇", - "category": "upgrade", - "requirement_type": "upgrade_low_prob_win", - "requirement_value": 5, - "reward_amount": 30000, - "sort_order": 43, - }, - { - "name": "upgrade_spent_100k", - "title": "Инвестор", - "description": "Суммарная стоимость проигранных предметов в апгрейдах — 100 000 ₽", - "icon": "💸", - "category": "upgrade", - "requirement_type": "upgrade_total_spent_value", - "requirement_value": 100000, - "reward_amount": 2000, - "sort_order": 44, - }, - { - "name": "upgrade_spent_1m", - "title": "Крупный инвестор", - "description": "Суммарная стоимость проигранных предметов в апгрейдах — 1 000 000 ₽", - "icon": "💸", - "category": "upgrade", - "requirement_type": "upgrade_total_spent_value", - "requirement_value": 1000000, - "reward_amount": 10000, - "sort_order": 45, - }, - { - "name": "upgrade_spent_10m", - "title": "Магнат", - "description": "Суммарная стоимость проигранных предметов в апгрейдах — 10 000 000 ₽", - "icon": "💰", - "category": "upgrade", - "requirement_type": "upgrade_total_spent_value", - "requirement_value": 10000000, - "reward_amount": 50000, - "sort_order": 46, - }, - { - "name": "upgrade_spent_100m", - "title": "Олигарх", - "description": "Суммарная стоимость проигранных предметов в апгрейдах — 100 000 000 ₽", - "icon": "💰", - "category": "upgrade", - "requirement_type": "upgrade_total_spent_value", - "requirement_value": 100000000, - "reward_amount": 200000, - "sort_order": 47, - }, - - # ═══════════════════════════════════════════════════════════════════════════ - # 📦 КЕЙСЫ (12 новых) - # ═══════════════════════════════════════════════════════════════════════════ - { - "name": "cases_2500", - "title": "Кейсоман", - "description": "Открой 2 500 кейсов", - "icon": "📦", - "category": "cases", - "requirement_type": "open_cases", - "requirement_value": 2500, - "reward_amount": 10000, - "sort_order": 48, - }, - { - "name": "cases_5000", - "title": "Индустрия кейсов", - "description": "Открой 5 000 кейсов", - "icon": "📦", - "category": "cases", - "requirement_type": "open_cases", - "requirement_value": 5000, - "reward_amount": 25000, - "sort_order": 49, - }, - { - "name": "cases_10000", - "title": "Кейс-магнат", - "description": "Открой 10 000 кейсов", - "icon": "🏭", - "category": "cases", - "requirement_type": "open_cases", - "requirement_value": 10000, - "reward_amount": 75000, - "sort_order": 50, - }, - { - "name": "cases_25000", - "title": "Повелитель кейсов", - "description": "Открой 25 000 кейсов", - "icon": "👑", - "category": "cases", - "requirement_type": "open_cases", - "requirement_value": 25000, - "reward_amount": 200000, - "sort_order": 51, - }, - { - "name": "slots_25", - "title": "Слот-любитель", - "description": "Сыграй 25 раундов в слот-машине", - "icon": "🎰", - "category": "cases", - "requirement_type": "slot_plays", - "requirement_value": 25, - "reward_amount": 1000, - "sort_order": 52, - }, - { - "name": "slots_100", - "title": "Слот-зависимый", - "description": "Сыграй 100 раундов в слот-машине", - "icon": "🎰", - "category": "cases", - "requirement_type": "slot_plays", - "requirement_value": 100, - "reward_amount": 5000, - "sort_order": 53, - }, - { - "name": "slots_500", - "title": "Слот-король", - "description": "Сыграй 500 раундов в слот-машине", - "icon": "👑", - "category": "cases", - "requirement_type": "slot_plays", - "requirement_value": 500, - "reward_amount": 25000, - "sort_order": 54, - }, - { - "name": "slots_jackpot_5", - "title": "Джекпот-хантер", - "description": "Собери 3 одинаковых символа в слотах 5 раз", - "icon": "🎯", - "category": "cases", - "requirement_type": "slot_match_count", - "requirement_value": 5, - "reward_amount": 10000, - "sort_order": 55, - }, - { - "name": "slots_jackpot_25", - "title": "Джекпот-легенда", - "description": "Собери 3 одинаковых символа в слотах 25 раз", - "icon": "🎯", - "category": "cases", - "requirement_type": "slot_match_count", - "requirement_value": 25, - "reward_amount": 50000, - "sort_order": 56, - }, - { - "name": "rare_item_10", - "title": "Охотник за редким II", - "description": "Выбей 10 предметов редкости Covert+", - "icon": "🔴", - "category": "cases", - "requirement_type": "rare_item_count", - "requirement_value": 10, - "reward_amount": 25000, - "sort_order": 57, - }, - { - "name": "rare_item_50", - "title": "Охотник за редким III", - "description": "Выбей 50 предметов редкости Covert+", - "icon": "🔴", - "category": "cases", - "requirement_type": "rare_item_count", - "requirement_value": 50, - "reward_amount": 100000, - "sort_order": 58, - }, - { - "name": "knife_5", - "title": "Ножевод II", - "description": "Выбей 5 ножей или перчаток", - "icon": "🔪", - "category": "cases", - "requirement_type": "knife_count", - "requirement_value": 5, - "reward_amount": 50000, - "sort_order": 59, - }, - - # ═══════════════════════════════════════════════════════════════════════════ - # 🎯 ОБЩИЕ (40 новых) - # ═══════════════════════════════════════════════════════════════════════════ - { - "name": "spent_500k", - "title": "Транжира II", - "description": "Потрать 500 000 ₽ на кейсы", - "icon": "💰", - "category": "general", - "requirement_type": "total_spent", - "requirement_value": 500000, - "reward_amount": 5000, - "sort_order": 60, - }, - { - "name": "spent_1m", - "title": "Транжира III", - "description": "Потрать 1 000 000 ₽ на кейсы", - "icon": "💰", - "category": "general", - "requirement_type": "total_spent", - "requirement_value": 1000000, - "reward_amount": 15000, - "sort_order": 61, - }, - { - "name": "spent_10m", - "title": "Транжира IV", - "description": "Потрать 10 000 000 ₽ на кейсы", - "icon": "💵", - "category": "general", - "requirement_type": "total_spent", - "requirement_value": 10000000, - "reward_amount": 50000, - "sort_order": 62, - }, - { - "name": "spent_100m", - "title": "Транжира V", - "description": "Потрать 100 000 000 ₽ на кейсы", - "icon": "💵", - "category": "general", - "requirement_type": "total_spent", - "requirement_value": 100000000, - "reward_amount": 200000, - "sort_order": 63, - }, - { - "name": "spent_500m", - "title": "Транжира VI", - "description": "Потрать 500 000 000 ₽ на кейсы", - "icon": "💎", - "category": "general", - "requirement_type": "total_spent", - "requirement_value": 500000000, - "reward_amount": 500000, - "sort_order": 64, - }, - { - "name": "spent_1b", - "title": "Транжира VII", - "description": "Потрать 1 000 000 000 ₽ на кейсы", - "icon": "💎", - "category": "general", - "requirement_type": "total_spent", - "requirement_value": 1000000000, - "reward_amount": 1000000, - "sort_order": 65, - }, - { - "name": "inventory_100", - "title": "Собиратель II", - "description": "Собери 100 предметов в инвентаре", - "icon": "🎒", - "category": "general", - "requirement_type": "inventory_count", - "requirement_value": 100, - "reward_amount": 1000, - "sort_order": 66, - }, - { - "name": "inventory_250", - "title": "Собиратель III", - "description": "Собери 250 предметов в инвентаре", - "icon": "🎒", - "category": "general", - "requirement_type": "inventory_count", - "requirement_value": 250, - "reward_amount": 2500, - "sort_order": 67, - }, - { - "name": "inventory_500", - "title": "Собиратель IV", - "description": "Собери 500 предметов в инвентаре", - "icon": "🎒", - "category": "general", - "requirement_type": "inventory_count", - "requirement_value": 500, - "reward_amount": 5000, - "sort_order": 68, - }, - { - "name": "inventory_1000", - "title": "Коллекционер", - "description": "Собери 1 000 предметов в инвентаре", - "icon": "🏛️", - "category": "general", - "requirement_type": "inventory_count", - "requirement_value": 1000, - "reward_amount": 10000, - "sort_order": 69, - }, - { - "name": "inventory_2500", - "title": "Музей", - "description": "Собери 2 500 предметов в инвентаре", - "icon": "🏛️", - "category": "general", - "requirement_type": "inventory_count", - "requirement_value": 2500, - "reward_amount": 25000, - "sort_order": 70, - }, - { - "name": "balance_1m", - "title": "Миллионер", - "description": "Накопи 1 000 000 ₽ на балансе", - "icon": "🤑", - "category": "general", - "requirement_type": "balance_milestone", - "requirement_value": 1000000, - "reward_amount": 10000, - "sort_order": 71, - }, - { - "name": "balance_10m", - "title": "Мультимиллионер", - "description": "Накопи 10 000 000 ₽ на балансе", - "icon": "🤑", - "category": "general", - "requirement_type": "balance_milestone", - "requirement_value": 10000000, - "reward_amount": 50000, - "sort_order": 72, - }, - { - "name": "balance_100m", - "title": "Богач", - "description": "Накопи 100 000 000 ₽ на балансе", - "icon": "💎", - "category": "general", - "requirement_type": "balance_milestone", - "requirement_value": 100000000, - "reward_amount": 250000, - "sort_order": 73, - }, - { - "name": "balance_1b", - "title": "Миллиардер", - "description": "Накопи 1 000 000 000 ₽ на балансе", - "icon": "💎", - "category": "general", - "requirement_type": "balance_milestone", - "requirement_value": 1000000000, - "reward_amount": 1000000, - "sort_order": 74, - }, - { - "name": "contracts_10", - "title": "Обменщик II", - "description": "Выполни 10 контрактов обмена", - "icon": "🔄", - "category": "general", - "requirement_type": "contracts", - "requirement_value": 10, - "reward_amount": 500, - "sort_order": 75, - }, - { - "name": "contracts_25", - "title": "Обменщик III", - "description": "Выполни 25 контрактов обмена", - "icon": "🔄", - "category": "general", - "requirement_type": "contracts", - "requirement_value": 25, - "reward_amount": 1500, - "sort_order": 76, - }, - { - "name": "contracts_100", - "title": "Обменщик IV", - "description": "Выполни 100 контрактов обмена", - "icon": "🔄", - "category": "general", - "requirement_type": "contracts", - "requirement_value": 100, - "reward_amount": 5000, - "sort_order": 77, - }, - { - "name": "contracts_250", - "title": "Мастер обмена", - "description": "Выполни 250 контрактов обмена", - "icon": "🔄", - "category": "general", - "requirement_type": "contracts", - "requirement_value": 250, - "reward_amount": 15000, - "sort_order": 78, - }, - { - "name": "crash_10", - "title": "Крэш-любитель", - "description": "Сыграй 10 раундов в Crash", - "icon": "💥", - "category": "crash", - "requirement_type": "crash_plays", - "requirement_value": 10, - "reward_amount": 500, - "sort_order": 79, - }, - { - "name": "crash_25", - "title": "Крэш-игрок", - "description": "Сыграй 25 раундов в Crash", - "icon": "💥", - "category": "crash", - "requirement_type": "crash_plays", - "requirement_value": 25, - "reward_amount": 1000, - "sort_order": 80, - }, - { - "name": "crash_100", - "title": "Крэш-фанат", - "description": "Сыграй 100 раундов в Crash", - "icon": "💥", - "category": "crash", - "requirement_type": "crash_plays", - "requirement_value": 100, - "reward_amount": 5000, - "sort_order": 81, - }, - { - "name": "crash_500", - "title": "Крэш-маньяк", - "description": "Сыграй 500 раундов в Crash", - "icon": "💥", - "category": "crash", - "requirement_type": "crash_plays", - "requirement_value": 500, - "reward_amount": 15000, - "sort_order": 82, - }, - { - "name": "crash_1000", - "title": "Крэш-легенда", - "description": "Сыграй 1000 раундов в Crash", - "icon": "💥", - "category": "crash", - "requirement_type": "crash_plays", - "requirement_value": 1000, - "reward_amount": 50000, - "sort_order": 83, - }, - { - "name": "crash_cashout_10x", - "title": "Смельчак II", - "description": "Выведи деньги на множителе x10 или выше в Crash", - "icon": "🚀", - "category": "crash", - "requirement_type": "crash_cashout_milestone", - "requirement_value": 10, - "reward_amount": 5000, - "sort_order": 84, - }, - { - "name": "crash_cashout_25x", - "title": "Смельчак III", - "description": "Выведи деньги на множителе x25 или выше в Crash", - "icon": "🚀", - "category": "crash", - "requirement_type": "crash_cashout_milestone", - "requirement_value": 25, - "reward_amount": 15000, - "sort_order": 85, - }, - { - "name": "crash_cashout_50x", - "title": "Смельчак IV", - "description": "Выведи деньги на множителе x50 или выше в Crash", - "icon": "🚀", - "category": "crash", - "requirement_type": "crash_cashout_milestone", - "requirement_value": 50, - "reward_amount": 50000, - "sort_order": 86, - }, - { - "name": "crash_cashout_100x", - "title": "Смельчак V", - "description": "Выведи деньги на множителе x100 или выше в Crash", - "icon": "🚀", - "category": "crash", - "requirement_type": "crash_cashout_milestone", - "requirement_value": 100, - "reward_amount": 200000, - "sort_order": 87, - }, - { - "name": "items_unboxed_100", - "title": "Распаковщик", - "description": "Открой 100 предметов из кейсов", - "icon": "🎁", - "category": "general", - "requirement_type": "items_unboxed", - "requirement_value": 100, - "reward_amount": 1000, - "sort_order": 88, - }, - { - "name": "items_unboxed_1000", - "title": "Распаковщик II", - "description": "Открой 1 000 предметов из кейсов", - "icon": "🎁", - "category": "general", - "requirement_type": "items_unboxed", - "requirement_value": 1000, - "reward_amount": 5000, - "sort_order": 89, - }, - { - "name": "items_unboxed_10000", - "title": "Распаковщик III", - "description": "Открой 10 000 предметов из кейсов", - "icon": "🎁", - "category": "general", - "requirement_type": "items_unboxed", - "requirement_value": 10000, - "reward_amount": 25000, - "sort_order": 90, - }, - { - "name": "total_profit_100k", - "title": "Первая прибыль", - "description": "Получи 100 000 ₽ чистой прибыли", - "icon": "📈", - "category": "general", - "requirement_type": "total_profit", - "requirement_value": 100000, - "reward_amount": 2000, - "sort_order": 91, - }, - { - "name": "total_profit_1m", - "title": "Прибыльный игрок", - "description": "Получи 1 000 000 ₽ чистой прибыли", - "icon": "📈", - "category": "general", - "requirement_type": "total_profit", - "requirement_value": 1000000, - "reward_amount": 10000, - "sort_order": 92, - }, - { - "name": "total_profit_10m", - "title": "Бизнесмен", - "description": "Получи 10 000 000 ₽ чистой прибыли", - "icon": "📊", - "category": "general", - "requirement_type": "total_profit", - "requirement_value": 10000000, - "reward_amount": 50000, - "sort_order": 93, - }, - { - "name": "total_profit_100m", - "title": "Магнат", - "description": "Получи 100 000 000 ₽ чистой прибыли", - "icon": "📊", - "category": "general", - "requirement_type": "total_profit", - "requirement_value": 100000000, - "reward_amount": 250000, - "sort_order": 94, - }, - { - "name": "achievements_10", - "title": "Достигатор", - "description": "Открой 10 достижений", - "icon": "🏆", - "category": "general", - "requirement_type": "achievements_count", - "requirement_value": 10, - "reward_amount": 2000, - "sort_order": 95, - }, - { - "name": "achievements_25", - "title": "Достигатор II", - "description": "Открой 25 достижений", - "icon": "🏆", - "category": "general", - "requirement_type": "achievements_count", - "requirement_value": 25, - "reward_amount": 5000, - "sort_order": 96, - }, - { - "name": "achievements_50", - "title": "Достигатор III", - "description": "Открой 50 достижений", - "icon": "🏆", - "category": "general", - "requirement_type": "achievements_count", - "requirement_value": 50, - "reward_amount": 15000, - "sort_order": 97, - }, - { - "name": "achievements_100", - "title": "Достигатор IV", - "description": "Открой 100 достижений", - "icon": "🏆", - "category": "general", - "requirement_type": "achievements_count", - "requirement_value": 100, - "reward_amount": 50000, - "sort_order": 98, - }, - { - "name": "crash_5000", - "title": "Крэш-бог", - "description": "Сыграй 5000 раундов в Crash", - "icon": "💥", - "category": "crash", - "requirement_type": "crash_plays", - "requirement_value": 5000, - "reward_amount": 200000, - "sort_order": 99, - }, - - # ═══════════════════════════════════════════════════════════════════════════ - # 🔞 ДИЛДО / ХАРД (20 новых) - # ═══════════════════════════════════════════════════════════════════════════ - { - "name": "dildo_owned", - "title": "Интересная находка", - "description": "Получи свой первый Dildo", - "icon": "🔞", - "category": "general", - "requirement_type": "dildo_count", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 100, - }, - { - "name": "dildo_5", - "title": "Коллекционер Dildo", - "description": "Собери 5 Dildo в инвентаре", - "icon": "🔞", - "category": "general", - "requirement_type": "dildo_count", - "requirement_value": 5, - "reward_amount": 15000, - "sort_order": 101, - }, - { - "name": "dildo_10", - "title": "Знаток Dildo", - "description": "Собери 10 Dildo в инвентаре", - "icon": "🔞", - "category": "general", - "requirement_type": "dildo_count", - "requirement_value": 10, - "reward_amount": 30000, - "sort_order": 102, - }, - { - "name": "dildo_25", - "title": "Dildo-маньяк", - "description": "Собери 25 Dildo в инвентаре", - "icon": "🔞", - "category": "general", - "requirement_type": "dildo_count", - "requirement_value": 25, - "reward_amount": 75000, - "sort_order": 103, - }, - { - "name": "dildo_50", - "title": "Dildo-барон", - "description": "Собери 50 Dildo в инвентаре", - "icon": "🔞", - "category": "general", - "requirement_type": "dildo_count", - "requirement_value": 50, - "reward_amount": 200000, - "sort_order": 104, - }, - { - "name": "dildo_100", - "title": "Dildo-император", - "description": "Собери 100 Dildo в инвентаре", - "icon": "👑", - "category": "general", - "requirement_type": "dildo_count", - "requirement_value": 100, - "reward_amount": 500000, - "sort_order": 105, - }, - { - "name": "item_worth_1m", - "title": "Ценная вещица", - "description": "Владей предметом дороже 1 000 000 ₽", - "icon": "💎", - "category": "general", - "requirement_type": "best_item_price", - "requirement_value": 1000000, - "reward_amount": 10000, - "sort_order": 106, - }, - { - "name": "item_worth_10m", - "title": "Дорогая игрушка", - "description": "Владей предметом дороже 10 000 000 ₽", - "icon": "💎", - "category": "general", - "requirement_type": "best_item_price", - "requirement_value": 10000000, - "reward_amount": 50000, - "sort_order": 107, - }, - { - "name": "item_worth_100m", - "title": "Бесценно", - "description": "Владей предметом дороже 100 000 000 ₽", - "icon": "💎", - "category": "general", - "requirement_type": "best_item_price", - "requirement_value": 100000000, - "reward_amount": 250000, - "sort_order": 108, - }, - { - "name": "item_worth_1b", - "title": "Священный Грааль", - "description": "Владей предметом дороже 1 000 000 000 ₽", - "icon": "👑", - "category": "general", - "requirement_type": "best_item_price", - "requirement_value": 1000000000, - "reward_amount": 2000000, - "sort_order": 109, - }, - { - "name": "inventory_value_10m", - "title": "Инвентарь миллионера", - "description": "Общая стоимость инвентаря > 10 000 000 ₽", - "icon": "💰", - "category": "general", - "requirement_type": "inventory_total_value", - "requirement_value": 10000000, - "reward_amount": 25000, - "sort_order": 110, - }, - { - "name": "inventory_value_100m", - "title": "Инвентарь магната", - "description": "Общая стоимость инвентаря > 100 000 000 ₽", - "icon": "💰", - "category": "general", - "requirement_type": "inventory_total_value", - "requirement_value": 100000000, - "reward_amount": 100000, - "sort_order": 111, - }, - { - "name": "inventory_value_1b", - "title": "Инвентарь миллиардера", - "description": "Общая стоимость инвентаря > 1 000 000 000 ₽", - "icon": "👑", - "category": "general", - "requirement_type": "inventory_total_value", - "requirement_value": 1000000000, - "reward_amount": 500000, - "sort_order": 112, - }, - { - "name": "dildo_float_001", - "title": "Идеальный Dildo", - "description": "Получи Dildo с float < 0.01", - "icon": "✨", - "category": "general", - "requirement_type": "dildo_best_float", - "requirement_value": 1, - "reward_amount": 50000, - "sort_order": 113, - }, - { - "name": "dildo_float_0005", - "title": "Безупречный Dildo", - "description": "Получи Dildo с float < 0.005", - "icon": "✨", - "category": "general", - "requirement_type": "dildo_best_float", - "requirement_value": 2, - "reward_amount": 250000, - "sort_order": 114, - }, - { - "name": "dildo_from_contract", - "title": "Контракт на Dildo", - "description": "Получи Dildo из контракта обмена", - "icon": "🔄", - "category": "general", - "requirement_type": "dildo_contract_count", - "requirement_value": 1, - "reward_amount": 20000, - "sort_order": 115, - }, - { - "name": "dildo_all_rarities", - "title": "Гамма Dildo", - "description": "Собери Dildo каждой редкости", - "icon": "🌈", - "category": "general", - "requirement_type": "dildo_all_rarities", - "requirement_value": 1, - "reward_amount": 100000, - "sort_order": 116, - }, - { - "name": "ultra_rare_float", - "title": "Совершенство", - "description": "Получи любой предмет с float 0.000X", - "icon": "💎", - "category": "general", - "requirement_type": "ultra_rare_float", - "requirement_value": 0.001, - "reward_amount": 500000, - "sort_order": 117, - }, - { - "name": "dildo_full_set", - "title": "Полная коллекция", - "description": "Собери 25 уникальных Dildo разных узоров", - "icon": "🏆", - "category": "general", - "requirement_type": "dildo_unique_count", - "requirement_value": 25, - "reward_amount": 1000000, - "sort_order": 118, - }, - { - "name": "dildo_worth_100m", - "title": "Dildo-магнат", - "description": "Общая стоимость всех Dildo в инвентаре > 100 000 000 ₽", - "icon": "💰", - "category": "general", - "requirement_type": "dildo_total_value", - "requirement_value": 100000000, - "reward_amount": 500000, - "sort_order": 119, - }, - - # ═══════════════════════════════════════════════════════════════════════════ - # ❓ СЕКРЕТНЫЕ (15 новых) - # ═══════════════════════════════════════════════════════════════════════════ - { - "name": "secret_midnight", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_midnight_case", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 200, - "hidden": True, - }, - { - "name": "secret_exact_balance", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_exact_balance", - "requirement_value": 1337, - "reward_amount": 5000, - "sort_order": 201, - "hidden": True, - }, - { - "name": "secret_99_fail", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_99_fail", - "requirement_value": 1, - "reward_amount": 10000, - "sort_order": 202, - "hidden": True, - }, - { - "name": "secret_negative_balance", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_negative_balance", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 203, - "hidden": True, - }, - { - "name": "secret_1000_exact", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_exact_cases", - "requirement_value": 1000, - "reward_amount": 10000, - "sort_order": 204, - "hidden": True, - }, - { - "name": "secret_empty_inventory", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_empty_inv", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 205, - "hidden": True, - }, - { - "name": "secret_spent_exact", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_spent_exact", - "requirement_value": 666666, - "reward_amount": 15000, - "sort_order": 206, - "hidden": True, - }, - { - "name": "secret_low_prob_win", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_low_prob_win", - "requirement_value": 1, - "reward_amount": 10000, - "sort_order": 207, - "hidden": True, - }, - { - "name": "secret_dildo_specific", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_dildo_specific", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 208, - "hidden": True, - }, - { - "name": "secret_crash_1x", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_crash_1x", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 209, - "hidden": True, - }, - { - "name": "secret_balance_777", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_balance_777", - "requirement_value": 777, - "reward_amount": 7777, - "sort_order": 210, - "hidden": True, - }, - { - "name": "secret_one_item_inv", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_one_item_inv", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 211, - "hidden": True, - }, - { - "name": "secret_contract_dildo", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_contract_dildo", - "requirement_value": 1, - "reward_amount": 10000, - "sort_order": 212, - "hidden": True, - }, - { - "name": "secret_slot_zero", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_slot_zero", - "requirement_value": 1, - "reward_amount": 15000, - "sort_order": 213, - "hidden": True, - }, - { - "name": "secret_exact_float", - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": "general", - "requirement_type": "secret_exact_float", - "requirement_value": 1, - "reward_amount": 50000, - "sort_order": 214, - "hidden": True, - }, - - # ═══════════════════════════════════════════════════════════════════════════ - # 🎨 КОЛЛЕКЦИИ (25 новых) - # ═══════════════════════════════════════════════════════════════════════════ - { - "name": "coll_first", - "title": "Первая коллекция", - "description": "Получи предмет, входящий в коллекцию", - "icon": "🎨", - "category": "general", - "requirement_type": "collection_count", - "requirement_value": 1, - "reward_amount": 500, - "sort_order": 120, - }, - { - "name": "coll_5", - "title": "Начинающий коллекционер", - "description": "Собери предметы из 5 разных коллекций", - "icon": "🎨", - "category": "general", - "requirement_type": "collection_count", - "requirement_value": 5, - "reward_amount": 1000, - "sort_order": 121, - }, - { - "name": "coll_10", - "title": "Коллекционер", - "description": "Собери предметы из 10 разных коллекций", - "icon": "🎨", - "category": "general", - "requirement_type": "collection_count", - "requirement_value": 10, - "reward_amount": 2500, - "sort_order": 122, - }, - { - "name": "coll_25", - "title": "Знаток коллекций", - "description": "Собери предметы из 25 разных коллекций", - "icon": "🏛️", - "category": "general", - "requirement_type": "collection_count", - "requirement_value": 25, - "reward_amount": 5000, - "sort_order": 123, - }, - { - "name": "coll_50", - "title": "Мастер коллекций", - "description": "Собери предметы из 50 разных коллекций", - "icon": "🏛️", - "category": "general", - "requirement_type": "collection_count", - "requirement_value": 50, - "reward_amount": 10000, - "sort_order": 124, - }, - { - "name": "coll_75", - "title": "Хранитель коллекций", - "description": "Собери предметы из 75 разных коллекций", - "icon": "🏛️", - "category": "general", - "requirement_type": "collection_count", - "requirement_value": 75, - "reward_amount": 25000, - "sort_order": 125, - }, - { - "name": "coll_100", - "title": "Архивариус", - "description": "Собери предметы из 100 разных коллекций", - "icon": "📚", - "category": "general", - "requirement_type": "collection_count", - "requirement_value": 100, - "reward_amount": 50000, - "sort_order": 126, - }, - { - "name": "coll_all_base", - "title": "Полная коллекция", - "description": "Собери все стандартные коллекции (без кастомных)", - "icon": "👑", - "category": "general", - "requirement_type": "collection_all_base", - "requirement_value": 1, - "reward_amount": 200000, - "sort_order": 127, - }, - { - "name": "coll_all", - "title": "Абсолютная коллекция", - "description": "Собери все коллекции, включая кастомные", - "icon": "👑", - "category": "general", - "requirement_type": "collection_all", - "requirement_value": 1, - "reward_amount": 1000000, - "sort_order": 128, - }, - { - "name": "coll_items_10", - "title": "Собиратель", - "description": "Собери 10 уникальных предметов из коллекций", - "icon": "🎨", - "category": "general", - "requirement_type": "collection_items", - "requirement_value": 10, - "reward_amount": 500, - "sort_order": 129, - }, - { - "name": "coll_items_50", - "title": "Коллекция растёт", - "description": "Собери 50 уникальных предметов из коллекций", - "icon": "🎨", - "category": "general", - "requirement_type": "collection_items", - "requirement_value": 50, - "reward_amount": 2000, - "sort_order": 130, - }, - { - "name": "coll_items_100", - "title": "Галерея", - "description": "Собери 100 уникальных предметов из коллекций", - "icon": "🖼️", - "category": "general", - "requirement_type": "collection_items", - "requirement_value": 100, - "reward_amount": 5000, - "sort_order": 131, - }, - { - "name": "coll_items_250", - "title": "Музей", - "description": "Собери 250 уникальных предметов из коллекций", - "icon": "🖼️", - "category": "general", - "requirement_type": "collection_items", - "requirement_value": 250, - "reward_amount": 15000, - "sort_order": 132, - }, - { - "name": "coll_items_500", - "title": "Эрмитаж", - "description": "Собери 500 уникальных предметов из коллекций", - "icon": "🏛️", - "category": "general", - "requirement_type": "collection_items", - "requirement_value": 500, - "reward_amount": 50000, - "sort_order": 133, - }, - { - "name": "coll_items_1000", - "title": "Лувр", - "description": "Собери 1000 уникальных предметов из коллекций", - "icon": "🏛️", - "category": "general", - "requirement_type": "collection_items", - "requirement_value": 1000, - "reward_amount": 150000, - "sort_order": 134, - }, - { - "name": "coll_complete_1", - "title": "Первая полная", - "description": "Собери все скины одной коллекции (полный сет)", - "icon": "🎯", - "category": "general", - "requirement_type": "collection_complete_count", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 135, - }, - { - "name": "coll_complete_5", - "title": "Набор коллекций", - "description": "Собери полностью 5 разных коллекций", - "icon": "🎯", - "category": "general", - "requirement_type": "collection_complete_count", - "requirement_value": 5, - "reward_amount": 20000, - "sort_order": 136, - }, - { - "name": "coll_complete_10", - "title": "Коллекционер-перфекционист", - "description": "Собери полностью 10 разных коллекций", - "icon": "🎯", - "category": "general", - "requirement_type": "collection_complete_count", - "requirement_value": 10, - "reward_amount": 50000, - "sort_order": 137, - }, - { - "name": "coll_complete_25", - "title": "Перфекционист", - "description": "Собери полностью 25 разных коллекций", - "icon": "🏆", - "category": "general", - "requirement_type": "collection_complete_count", - "requirement_value": 25, - "reward_amount": 150000, - "sort_order": 138, - }, - { - "name": "coll_complete_50", - "title": "Абсолютный перфекционист", - "description": "Собери полностью 50 разных коллекций", - "icon": "🏆", - "category": "general", - "requirement_type": "collection_complete_count", - "requirement_value": 50, - "reward_amount": 500000, - "sort_order": 139, - }, - { - "name": "coll_cheap_collector", - "title": "От простого", - "description": "Собери все Consumer Grade скины любой коллекции", - "icon": "⬜", - "category": "general", - "requirement_type": "coll_rarity_complete", - "requirement_value": 1, - "reward_amount": 1000, - "sort_order": 140, - }, - { - "name": "coll_mid_collector", - "title": "Середина", - "description": "Собери все Mil-Spec скины любой коллекции", - "icon": "🟦", - "category": "general", - "requirement_type": "coll_rarity_complete", - "requirement_value": 2, - "reward_amount": 3000, - "sort_order": 141, - }, - { - "name": "coll_rare_collector", - "title": "Элита", - "description": "Собери все Restricted+ скины любой коллекции", - "icon": "🟣", - "category": "general", - "requirement_type": "coll_rarity_complete", - "requirement_value": 3, - "reward_amount": 10000, - "sort_order": 142, - }, - { - "name": "coll_covert_collector", - "title": "Крем", - "description": "Собери все Covert скины любой коллекции", - "icon": "🔴", - "category": "general", - "requirement_type": "coll_rarity_complete", - "requirement_value": 4, - "reward_amount": 30000, - "sort_order": 143, - }, - { - "name": "coll_full_case", - "title": "Кейс закрыт", - "description": "Собери все предметы из одного кейса", - "icon": "📦", - "category": "general", - "requirement_type": "coll_case_complete", - "requirement_value": 1, - "reward_amount": 5000, - "sort_order": 144, - }, + {"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": "Выполни 10 контрактов обмена", "icon": "🔄", "category": "contracts", "requirement_type": "contracts", "requirement_value": 10, "reward_amount": 500, "sort_order": 6}, + {"name": "first_upgrade", "title": "Улучшайзер", "description": "Проведи первый апгрейд", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 1, "reward_amount": 150, "sort_order": 7}, + {"name": "upgrade_winner", "title": "Везучий улучшайзер", "description": "Выиграй первый апгрейд", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_wins", "requirement_value": 1, "reward_amount": 300, "sort_order": 8}, + {"name": "rare_finder", "title": "Охотник за редким", "description": "Найди редкий предмет", "icon": "⭐", "category": "cases", "requirement_type": "find_rare", "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": 1000, "sort_order": 10}, + {"name": "big_spender", "title": "Транжира", "description": "Потрать 10,000 ₽ на кейсы", "icon": "💰", "category": "spending", "requirement_type": "total_spent", "requirement_value": 10000, "reward_amount": 500, "sort_order": 11}, + {"name": "whale", "title": "Кит", "description": "Потрать 100,000 ₽ на кейсы", "icon": "🐳", "category": "spending", "requirement_type": "total_spent", "requirement_value": 100000, "reward_amount": 2000, "sort_order": 12}, + {"name": "rich_100k", "title": "Богач", "description": "Накопи 100,000 ₽ на балансе", "icon": "💎", "category": "wealth", "requirement_type": "balance", "requirement_value": 1, "reward_amount": 1000, "sort_order": 13, "hidden": False}, + {"name": "slot_winner", "title": "Счастливчик", "description": "Выиграй в слот-машине", "icon": "🎰", "category": "slots", "requirement_type": "slot_match", "requirement_value": 1, "reward_amount": 500, "sort_order": 14}, + {"name": "coll_first", "title": "Коллекция! :3", "description": "Собери предметы из 1 коллекции", "icon": "📚", "category": "collections", "requirement_type": "collections_owned", "requirement_value": 1, "reward_amount": 500, "sort_order": 15}, + {"name": "coll_5", "title": "Коллекционер коллекций", "description": "Собери предметы из 5 коллекций", "icon": "📚", "category": "collections", "requirement_type": "collections_owned", "requirement_value": 5, "reward_amount": 1000, "sort_order": 16}, + {"name": "coll_10", "title": "Заядлый коллекционер коллекций", "description": "Собери предметы из 10 коллекций", "icon": "📚", "category": "collections", "requirement_type": "collections_owned", "requirement_value": 10, "reward_amount": 2000, "sort_order": 17}, + {"name": "coll_25", "title": "Коллекционер-эксперт", "description": "Собери предметы из 25 коллекций", "icon": "📚", "category": "collections", "requirement_type": "collections_owned", "requirement_value": 25, "reward_amount": 5000, "sort_order": 18}, + {"name": "coll_50", "title": "Коллекционер-мастер", "description": "Собери предметы из 50 коллекций", "icon": "📚", "category": "collections", "requirement_type": "collections_owned", "requirement_value": 50, "reward_amount": 10000, "sort_order": 19}, + {"name": "coll_75", "title": "Архивариус", "description": "Собери предметы из 75 коллекций", "icon": "📚", "category": "collections", "requirement_type": "collections_owned", "requirement_value": 75, "reward_amount": 25000, "sort_order": 20}, + {"name": "coll_100", "title": "Библиотекарь", "description": "Собери предметы из 100 коллекций", "icon": "📚", "category": "collections", "requirement_type": "collections_owned", "requirement_value": 100, "reward_amount": 50000, "sort_order": 21}, + {"name": "coll_complete_1", "title": "Полная коллекция", "description": "Заверши 1 коллекцию полностью", "icon": "🏆", "category": "collections", "requirement_type": "collections_complete", "requirement_value": 1, "reward_amount": 1000, "sort_order": 22}, + {"name": "coll_complete_5", "title": "Коллекция завершена", "description": "Заверши 5 коллекций полностью", "icon": "🏆", "category": "collections", "requirement_type": "collections_complete", "requirement_value": 5, "reward_amount": 5000, "sort_order": 23}, + {"name": "coll_complete_10", "title": "Мастер завершения", "description": "Заверши 10 коллекций полностью", "icon": "🏆", "category": "collections", "requirement_type": "collections_complete", "requirement_value": 10, "reward_amount": 10000, "sort_order": 24}, + {"name": "coll_complete_25", "title": "Перфекционист", "description": "Заверши 25 коллекций полностью", "icon": "🏆", "category": "collections", "requirement_type": "collections_complete", "requirement_value": 25, "reward_amount": 50000, "sort_order": 25}, + {"name": "coll_complete_50", "title": "Абсолютный коллекционер", "description": "Заверши 50 коллекций полностью", "icon": "👑", "category": "collections", "requirement_type": "collections_complete", "requirement_value": 50, "reward_amount": 100000, "sort_order": 26}, + {"name": "coll_items_10", "title": "Начинающий коллекционер", "description": "Собери 10 уникальных скинов", "icon": "🎨", "category": "collections", "requirement_type": "unique_skins", "requirement_value": 10, "reward_amount": 500, "sort_order": 27}, + {"name": "coll_items_50", "title": "Ценитель искусства", "description": "Собери 50 уникальных скинов", "icon": "🎨", "category": "collections", "requirement_type": "unique_skins", "requirement_value": 50, "reward_amount": 1000, "sort_order": 28}, + {"name": "coll_items_100", "title": "Галерист", "description": "Собери 100 уникальных скинов", "icon": "🎨", "category": "collections", "requirement_type": "unique_skins", "requirement_value": 100, "reward_amount": 2000, "sort_order": 29}, + {"name": "coll_items_250", "title": "Музейный работник", "description": "Собери 250 уникальных скинов", "icon": "🎨", "category": "collections", "requirement_type": "unique_skins", "requirement_value": 250, "reward_amount": 5000, "sort_order": 30}, + {"name": "coll_items_500", "title": "Эстет", "description": "Собери 500 уникальных скинов", "icon": "🎨", "category": "collections", "requirement_type": "unique_skins", "requirement_value": 500, "reward_amount": 10000, "sort_order": 31}, + {"name": "coll_items_1000", "title": "Синефил", "description": "Собери 1000 уникальных скинов", "icon": "🎨", "category": "collections", "requirement_type": "unique_skins", "requirement_value": 1000, "reward_amount": 25000, "sort_order": 32}, + {"name": "coll_all_base", "title": "Базовый коллекционер", "description": "Собери все скины из всех базовых коллекций", "icon": "💠", "category": "collections", "requirement_type": "all_base_collections", "requirement_value": 1, "reward_amount": 100000, "sort_order": 33}, + {"name": "coll_all", "title": "Властелин коллекций", "description": "Собери все скины из ВСЕХ коллекций (включая кастом)", "icon": "👑", "category": "collections", "requirement_type": "all_collections", "requirement_value": 1, "reward_amount": 500000, "sort_order": 34}, + {"name": "coll_cheap_collector", "title": "Бюджетный коллекционер", "description": "Заверши любую коллекцию только из Consumer Grade предметов", "icon": "🪙", "category": "collections", "requirement_type": "cheap_collection", "requirement_value": 1, "reward_amount": 1000, "sort_order": 35}, + {"name": "coll_mid_collector", "title": "Середина", "description": "Заверши коллекцию только из Mil-Spec предметов", "icon": "🥈", "category": "collections", "requirement_type": "mid_collection", "requirement_value": 1, "reward_amount": 2000, "sort_order": 36}, + {"name": "coll_rare_collector", "title": "Элитарный коллекционер", "description": "Заверши коллекцию только из редких предметов (Restricted+)", "icon": "💎", "category": "collections", "requirement_type": "rare_collection", "requirement_value": 1, "reward_amount": 5000, "sort_order": 37}, + {"name": "coll_covert_collector", "title": "Тайный коллекционер", "description": "Заверши коллекцию только из Covert предметов", "icon": "🔮", "category": "collections", "requirement_type": "covert_collection", "requirement_value": 1, "reward_amount": 10000, "sort_order": 38}, + {"name": "coll_full_case", "title": "Кейс-коллекционер", "description": "Собери полный набор из любого кейса", "icon": "📦", "category": "collections", "requirement_type": "full_case", "requirement_value": 1, "reward_amount": 2000, "sort_order": 39}, + {"name": "dildo_owned", "title": "Ахтунг", "description": "Получи первый Dildo", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_count", "requirement_value": 1, "reward_amount": 100, "sort_order": 40, "hidden": True}, + {"name": "dildo_5", "title": "Диванный эксперт", "description": "Собери 5 Dildo", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_count", "requirement_value": 5, "reward_amount": 500, "sort_order": 41, "hidden": True}, + {"name": "dildo_10", "title": "Вечеринка", "description": "Собери 10 Dildo", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_count", "requirement_value": 10, "reward_amount": 1000, "sort_order": 42, "hidden": True}, + {"name": "dildo_25", "title": "Промышленные масштабы", "description": "Собери 25 Dildo", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_count", "requirement_value": 25, "reward_amount": 5000, "sort_order": 43, "hidden": True}, + {"name": "dildo_50", "title": "Склад готовой продукции", "description": "Собери 50 Dildo", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_count", "requirement_value": 50, "reward_amount": 10000, "sort_order": 44, "hidden": True}, + {"name": "dildo_all_rarities", "title": "Dildo всех редкостей", "description": "Собери Dildo хотя бы одной каждой редкости", "icon": "🌈", "category": "dildo", "requirement_type": "dildo_rarities", "requirement_value": 1, "reward_amount": 5000, "sort_order": 46, "hidden": True}, + {"name": "dildo_full_set", "title": "Dildo-коллекция", "description": "Собери все паттерны Dildo", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_patterns", "requirement_value": 1, "reward_amount": 25000, "sort_order": 47, "hidden": True}, + {"name": "dildo_float_001", "title": "Идеальный Dildo", "description": "Найди Dildo с float < 0.01", "icon": "✨", "category": "dildo", "requirement_type": "dildo_best_float", "requirement_value": 1, "reward_amount": 5000, "sort_order": 48, "hidden": True}, + {"name": "dildo_float_0005", "title": "Девственный Dildo", "description": "Найди Dildo с float < 0.005", "icon": "💎", "category": "dildo", "requirement_type": "dildo_best_float_2", "requirement_value": 2, "reward_amount": 15000, "sort_order": 49, "hidden": True}, + {"name": "crash_first", "title": "Крэшер", "description": "Сыграй первую игру в Crash", "icon": "📈", "category": "crash", "requirement_type": "crash_games", "requirement_value": 1, "reward_amount": 100, "sort_order": 50}, + {"name": "crash_pro", "title": "Крэш-профи", "description": "Сыграй 10 игр в Crash", "icon": "📈", "category": "crash", "requirement_type": "crash_games", "requirement_value": 10, "reward_amount": 500, "sort_order": 51}, + {"name": "crash_5x", "title": "Крэш-5x", "description": "Забери деньги на множителе 5x", "icon": "📈", "category": "crash", "requirement_type": "crash_cashout", "requirement_value": 5, "reward_amount": 1000, "sort_order": 52}, + {"name": "inventory_50", "title": "Инвентарь 50", "description": "Собери 50 предметов в инвентаре", "icon": "🎒", "category": "inventory", "requirement_type": "inventory_items", "requirement_value": 50, "reward_amount": 500, "sort_order": 53}, + {"name": "inventory_100", "title": "Инвентарь 100", "description": "Собери 100 предметов в инвентаре", "icon": "🎒", "category": "inventory", "requirement_type": "inventory_items", "requirement_value": 100, "reward_amount": 1000, "sort_order": 54}, + {"name": "inventory_250", "title": "Инвентарь 250", "description": "Собери 250 предметов в инвентаре", "icon": "🎒", "category": "inventory", "requirement_type": "inventory_items", "requirement_value": 250, "reward_amount": 5000, "sort_order": 55}, + {"name": "inventory_500", "title": "Инвентарь 500", "description": "Собери 500 предметов в инвентаре", "icon": "🎒", "category": "inventory", "requirement_type": "inventory_items", "requirement_value": 500, "reward_amount": 10000, "sort_order": 56}, + {"name": "inventory_1000", "title": "Инвентарь 1000", "description": "Собери 1000 предметов в инвентаре", "icon": "🎒", "category": "inventory", "requirement_type": "inventory_items", "requirement_value": 1000, "reward_amount": 25000, "sort_order": 57}, + {"name": "contracts_10", "title": "Контракты 10", "description": "Выполни 10 контрактов", "icon": "🔄", "category": "contracts", "requirement_type": "contracts", "requirement_value": 10, "reward_amount": 1000, "sort_order": 58}, + {"name": "contracts_25", "title": "Контракты 25", "description": "Выполни 25 контрактов", "icon": "🔄", "category": "contracts", "requirement_type": "contracts", "requirement_value": 25, "reward_amount": 3000, "sort_order": 59}, + {"name": "contracts_100", "title": "Контракты 100", "description": "Выполни 100 контрактов", "icon": "🔄", "category": "contracts", "requirement_type": "contracts", "requirement_value": 100, "reward_amount": 10000, "sort_order": 60}, + {"name": "upgrade_5", "title": "Апгрейды 5", "description": "Проведи 5 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 5, "reward_amount": 500, "sort_order": 61}, + {"name": "upgrade_25", "title": "Апгрейды 25", "description": "Проведи 25 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 25, "reward_amount": 2000, "sort_order": 62}, + {"name": "upgrade_50", "title": "Апгрейды 50", "description": "Проведи 50 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 50, "reward_amount": 5000, "sort_order": 63}, + {"name": "upgrade_100", "title": "Апгрейд-мастер", "description": "Проведи 100 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 100, "reward_amount": 10000, "sort_order": 64}, + {"name": "upgrade_wins_5", "title": "5 побед в апгрейде", "description": "Выиграй 5 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_wins", "requirement_value": 5, "reward_amount": 500, "sort_order": 65}, + {"name": "upgrade_wins_25", "title": "25 побед в апгрейде", "description": "Выиграй 25 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_wins", "requirement_value": 25, "reward_amount": 2500, "sort_order": 66}, + {"name": "upgrade_wins_50", "title": "50 побед в апгрейде", "description": "Выиграй 50 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_wins", "requirement_value": 50, "reward_amount": 10000, "sort_order": 67}, + {"name": "upgrade_wins_100", "title": "100 побед в апгрейде", "description": "Выиграй 100 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_wins", "requirement_value": 100, "reward_amount": 25000, "sort_order": 68}, + {"name": "dildo_100", "title": "Dildo-магнат", "description": "Собери 100 Dildo", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_count", "requirement_value": 100, "reward_amount": 50000, "sort_order": 69, "hidden": True}, + {"name": "dildo_worth_100m", "title": "Dildo-миллиардер", "description": "Стоимость Dildo в инвентаре превышает 100 млн ₽", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_total_value", "requirement_value": 100000000, "reward_amount": 100000, "sort_order": 70, "hidden": True}, + {"name": "dildo_from_contract", "title": "Dildo из контракта", "description": "Получи Dildo из контракта обмена", "icon": "🍆", "category": "dildo", "requirement_type": "dildo_contract", "requirement_value": 1, "reward_amount": 5000, "sort_order": 71, "hidden": True}, + {"name": "balance_1m", "title": "Баланс 1 млн", "description": "Накопи 1,000,000 ₽ на балансе", "icon": "💰", "category": "wealth", "requirement_type": "balance", "requirement_value": 1000000, "reward_amount": 5000, "sort_order": 72}, + {"name": "balance_10m", "title": "Баланс 10 млн", "description": "Накопи 10,000,000 ₽ на балансе", "icon": "💰", "category": "wealth", "requirement_type": "balance", "requirement_value": 10000000, "reward_amount": 25000, "sort_order": 73}, + {"name": "balance_100m", "title": "Баланс 100 млн", "description": "Накопи 100,000,000 ₽ на балансе", "icon": "💰", "category": "wealth", "requirement_type": "balance", "requirement_value": 100000000, "reward_amount": 100000, "sort_order": 74}, + {"name": "balance_1b", "title": "Баланс 1 млрд", "description": "Накопи 1,000,000,000 ₽ на балансе", "icon": "💎", "category": "wealth", "requirement_type": "balance", "requirement_value": 1000000000, "reward_amount": 500000, "sort_order": 75}, + {"name": "item_worth_1m", "title": "Ценный предмет I", "description": "Владей предметом стоимостью от 1 млн ₽", "icon": "💎", "category": "items", "requirement_type": "best_item", "requirement_value": 1000000, "reward_amount": 1000, "sort_order": 76}, + {"name": "item_worth_10m", "title": "Ценный предмет II", "description": "Владей предметом стоимостью от 10 млн ₽", "icon": "💎", "category": "items", "requirement_type": "best_item", "requirement_value": 10000000, "reward_amount": 5000, "sort_order": 77}, + {"name": "item_worth_100m", "title": "Ценный предмет III", "description": "Владей предметом стоимостью от 100 млн ₽", "icon": "💎", "category": "items", "requirement_type": "best_item", "requirement_value": 100000000, "reward_amount": 25000, "sort_order": 78}, + {"name": "item_worth_1b", "title": "Ценный предмет IV", "description": "Владей предметом стоимостью от 1 млрд ₽", "icon": "💎", "category": "items", "requirement_type": "best_item", "requirement_value": 1000000000, "reward_amount": 100000, "sort_order": 79}, + {"name": "inventory_value_10m", "title": "Инвентарь 10 млн", "description": "Стоимость инвентаря превысила 10 млн ₽", "icon": "💰", "category": "inventory", "requirement_type": "inventory_total_value", "requirement_value": 10000000, "reward_amount": 5000, "sort_order": 80}, + {"name": "inventory_value_100m", "title": "Инвентарь 100 млн", "description": "Стоимость инвентаря превысила 100 млн ₽", "icon": "💰", "category": "inventory", "requirement_type": "inventory_total_value", "requirement_value": 100000000, "reward_amount": 25000, "sort_order": 81}, + {"name": "inventory_value_1b", "title": "Инвентарь 1 млрд", "description": "Стоимость инвентаря превысила 1 млрд ₽", "icon": "💰", "category": "inventory", "requirement_type": "inventory_total_value", "requirement_value": 1000000000, "reward_amount": 100000, "sort_order": 82}, + {"name": "spent_500k", "title": "Потрачено 500к", "description": "Потрать 500,000 ₽ на кейсы", "icon": "💸", "category": "spending", "requirement_type": "total_spent", "requirement_value": 500000, "reward_amount": 5000, "sort_order": 83}, + {"name": "spent_1m", "title": "Потрачен миллион", "description": "Потрать 1,000,000 ₽ на кейсы", "icon": "💸", "category": "spending", "requirement_type": "total_spent", "requirement_value": 1000000, "reward_amount": 10000, "sort_order": 84}, + {"name": "spent_10m", "title": "Потрачено 10 млн", "description": "Потрать 10,000,000 ₽ на кейсы", "icon": "💸", "category": "spending", "requirement_type": "total_spent", "requirement_value": 10000000, "reward_amount": 50000, "sort_order": 85}, + {"name": "spent_100m", "title": "Потрачено 100 млн", "description": "Потрать 100,000,000 ₽ на кейсы", "icon": "💸", "category": "spending", "requirement_type": "total_spent", "requirement_value": 100000000, "reward_amount": 100000, "sort_order": 86}, + {"name": "spent_500m", "title": "Потрачено 500 млн", "description": "Потрать 500,000,000 ₽ на кейсы", "icon": "💸", "category": "spending", "requirement_type": "total_spent", "requirement_value": 500000000, "reward_amount": 500000, "sort_order": 87}, + {"name": "spent_1b", "title": "Потрачен миллиард", "description": "Потрать 1,000,000,000 ₽ на кейсы", "icon": "💎", "category": "spending", "requirement_type": "total_spent", "requirement_value": 1000000000, "reward_amount": 1000000, "sort_order": 88}, + {"name": "cases_2500", "title": "2500 кейсов", "description": "Открой 2500 кейсов", "icon": "📦", "category": "cases", "requirement_type": "open_cases", "requirement_value": 2500, "reward_amount": 10000, "sort_order": 89}, + {"name": "cases_5000", "title": "5000 кейсов", "description": "Открой 5000 кейсов", "icon": "📦", "category": "cases", "requirement_type": "open_cases", "requirement_value": 5000, "reward_amount": 25000, "sort_order": 90}, + {"name": "cases_10000", "title": "10000 кейсов", "description": "Открой 10000 кейсов", "icon": "💎", "category": "cases", "requirement_type": "open_cases", "requirement_value": 10000, "reward_amount": 100000, "sort_order": 91}, + {"name": "cases_25000", "title": "25000 кейсов", "description": "Открой 25000 кейсов", "icon": "💎", "category": "cases", "requirement_type": "open_cases", "requirement_value": 25000, "reward_amount": 500000, "sort_order": 92}, + {"name": "contracts_250", "title": "250 контрактов", "description": "Выполни 250 контрактов", "icon": "🔄", "category": "contracts", "requirement_type": "contracts", "requirement_value": 250, "reward_amount": 50000, "sort_order": 93}, + {"name": "crash_10", "title": "10 игр в Crash", "description": "Сыграй 10 игр в Crash", "icon": "📈", "category": "crash", "requirement_type": "crash_games", "requirement_value": 10, "reward_amount": 1000, "sort_order": 94}, + {"name": "crash_25", "title": "25 игр в Crash", "description": "Сыграй 25 игр в Crash", "icon": "📈", "category": "crash", "requirement_type": "crash_games", "requirement_value": 25, "reward_amount": 2500, "sort_order": 95}, + {"name": "crash_100", "title": "100 игр в Crash", "description": "Сыграй 100 игр в Crash", "icon": "📈", "category": "crash", "requirement_type": "crash_games", "requirement_value": 100, "reward_amount": 10000, "sort_order": 96}, + {"name": "crash_500", "title": "500 игр в Crash", "description": "Сыграй 500 игр в Crash", "icon": "📈", "category": "crash", "requirement_type": "crash_games", "requirement_value": 500, "reward_amount": 50000, "sort_order": 97}, + {"name": "crash_1000", "title": "1000 игр в Crash", "description": "Сыграй 1000 игр в Crash", "icon": "📈", "category": "crash", "requirement_type": "crash_games", "requirement_value": 1000, "reward_amount": 100000, "sort_order": 98}, + {"name": "crash_5000", "title": "5000 игр в Crash", "description": "Сыграй 5000 игр в Crash", "icon": "📈", "category": "crash", "requirement_type": "crash_games", "requirement_value": 5000, "reward_amount": 500000, "sort_order": 99}, + {"name": "crash_cashout_10x", "title": "Кэшаут 10x", "description": "Забери деньги на множителе 10x", "icon": "📈", "category": "crash", "requirement_type": "crash_cashout", "requirement_value": 10, "reward_amount": 2000, "sort_order": 100}, + {"name": "crash_cashout_25x", "title": "Кэшаут 25x", "description": "Забери деньги на множителе 25x", "icon": "📈", "category": "crash", "requirement_type": "crash_cashout", "requirement_value": 25, "reward_amount": 5000, "sort_order": 101}, + {"name": "crash_cashout_50x", "title": "Кэшаут 50x", "description": "Забери деньги на множителе 50x", "icon": "📈", "category": "crash", "requirement_type": "crash_cashout", "requirement_value": 50, "reward_amount": 15000, "sort_order": 102}, + {"name": "crash_cashout_100x", "title": "Кэшаут 100x", "description": "Забери деньги на множителе 100x", "icon": "🔥", "category": "crash", "requirement_type": "crash_cashout", "requirement_value": 100, "reward_amount": 50000, "sort_order": 103}, + {"name": "upgrade_250", "title": "250 апгрейдов", "description": "Проведи 250 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 250, "reward_amount": 25000, "sort_order": 104}, + {"name": "upgrade_500", "title": "500 апгрейдов", "description": "Проведи 500 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 500, "reward_amount": 50000, "sort_order": 105}, + {"name": "upgrade_1000", "title": "1000 апгрейдов", "description": "Проведи 1000 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 1000, "reward_amount": 100000, "sort_order": 106}, + {"name": "upgrade_2000", "title": "2000 апгрейдов", "description": "Проведи 2000 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_attempts", "requirement_value": 2000, "reward_amount": 250000, "sort_order": 107}, + {"name": "upgrade_wins_250", "title": "250 побед в апгрейде", "description": "Выиграй 250 апгрейдов", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_wins", "requirement_value": 250, "reward_amount": 100000, "sort_order": 108}, + {"name": "upgrade_consecutive_wins_3", "title": "3 победы подряд", "description": "Выиграй 3 апгрейда подряд", "icon": "🔥", "category": "upgrade", "requirement_type": "upgrade_consecutive_wins", "requirement_value": 3, "reward_amount": 500, "sort_order": 109}, + {"name": "upgrade_consecutive_wins_5", "title": "5 побед подряд", "description": "Выиграй 5 апгрейдов подряд", "icon": "🔥", "category": "upgrade", "requirement_type": "upgrade_consecutive_wins", "requirement_value": 5, "reward_amount": 2000, "sort_order": 110}, + {"name": "upgrade_consecutive_wins_10", "title": "10 побед подряд", "description": "Выиграй 10 апгрейдов подряд", "icon": "🔥", "category": "upgrade", "requirement_type": "upgrade_consecutive_wins", "requirement_value": 10, "reward_amount": 10000, "sort_order": 111}, + {"name": "upgrade_consecutive_losses_5", "title": "5 поражений подряд", "description": "Проиграй 5 апгрейдов подряд", "icon": "💀", "category": "upgrade", "requirement_type": "upgrade_consecutive_losses", "requirement_value": 5, "reward_amount": 1000, "sort_order": 112}, + {"name": "upgrade_consecutive_losses_10", "title": "10 поражений подряд", "description": "Проиграй 10 апгрейдов подряд", "icon": "💀", "category": "upgrade", "requirement_type": "upgrade_consecutive_losses", "requirement_value": 10, "reward_amount": 5000, "sort_order": 113}, + {"name": "upgrade_miracle", "title": "Чудо", "description": "Выиграй апгрейд с шансом <10%", "icon": "🙏", "category": "upgrade", "requirement_type": "low_prob_wins", "requirement_value": 1, "reward_amount": 500, "sort_order": 114}, + {"name": "upgrade_miracle_5", "title": "Чудотворец", "description": "Выиграй 5 апгрейдов с шансом <10%", "icon": "🙏", "category": "upgrade", "requirement_type": "low_prob_wins", "requirement_value": 5, "reward_amount": 5000, "sort_order": 115}, + {"name": "upgrade_spent_100k", "title": "Потери 100к", "description": "Потеряй предметы на 100,000 ₽ в неудачных апгрейдах", "icon": "💸", "category": "upgrade", "requirement_type": "upgrade_loss_value", "requirement_value": 100000, "reward_amount": 2000, "sort_order": 116}, + {"name": "upgrade_spent_1m", "title": "Потери 1 млн", "description": "Потеряй предметы на 1,000,000 ₽ в неудачных апгрейдах", "icon": "💸", "category": "upgrade", "requirement_type": "upgrade_loss_value", "requirement_value": 1000000, "reward_amount": 10000, "sort_order": 117}, + {"name": "upgrade_spent_10m", "title": "Потери 10 млн", "description": "Потеряй предметы на 10,000,000 ₽ в неудачных апгрейдах", "icon": "💸", "category": "upgrade", "requirement_type": "upgrade_loss_value", "requirement_value": 10000000, "reward_amount": 50000, "sort_order": 118}, + {"name": "upgrade_spent_100m", "title": "Потери 100 млн", "description": "Потеряй предметы на 100,000,000 ₽ в неудачных апгрейдах", "icon": "💸", "category": "upgrade", "requirement_type": "upgrade_loss_value", "requirement_value": 100000000, "reward_amount": 250000, "sort_order": 119}, + {"name": "upgrade_high_risk_50k", "title": "Риск 50к", "description": "Поставь в апгрейд предмет стоимостью 50,000 ₽", "icon": "🎲", "category": "upgrade", "requirement_type": "upgrade_risk_value", "requirement_value": 50000, "reward_amount": 500, "sort_order": 120}, + {"name": "upgrade_high_risk_500k", "title": "Риск 500к", "description": "Поставь в апгрейд предмет стоимостью 500,000 ₽", "icon": "🎲", "category": "upgrade", "requirement_type": "upgrade_risk_value", "requirement_value": 500000, "reward_amount": 5000, "sort_order": 121}, + {"name": "upgrade_high_risk_10m", "title": "Риск 10 млн", "description": "Поставь в апгрейд предмет стоимостью 10,000,000 ₽", "icon": "🎲", "category": "upgrade", "requirement_type": "upgrade_risk_value", "requirement_value": 10000000, "reward_amount": 50000, "sort_order": 122}, + {"name": "slots_10", "title": "10 игр в слоты", "description": "Сыграй 10 раз в слот-машину", "icon": "🎰", "category": "slots", "requirement_type": "slot_plays", "requirement_value": 10, "reward_amount": 500, "sort_order": 123}, + {"name": "slots_25", "title": "25 игр в слоты", "description": "Сыграй 25 раз в слот-машину", "icon": "🎰", "category": "slots", "requirement_type": "slot_plays", "requirement_value": 25, "reward_amount": 1000, "sort_order": 124}, + {"name": "slots_100", "title": "100 игр в слоты", "description": "Сыграй 100 раз в слот-машину", "icon": "🎰", "category": "slots", "requirement_type": "slot_plays", "requirement_value": 100, "reward_amount": 5000, "sort_order": 125}, + {"name": "slots_500", "title": "500 игр в слоты", "description": "Сыграй 500 раз в слот-машину", "icon": "🎰", "category": "slots", "requirement_type": "slot_plays", "requirement_value": 500, "reward_amount": 20000, "sort_order": 126}, + {"name": "slots_jackpot_5", "title": "5 джекпотов", "description": "Собери 5 совпадений в слотах", "icon": "🎰", "category": "slots", "requirement_type": "slot_jackpots", "requirement_value": 5, "reward_amount": 2000, "sort_order": 127}, + {"name": "slots_jackpot_25", "title": "25 джекпотов", "description": "Собери 25 совпадений в слотах", "icon": "🎰", "category": "slots", "requirement_type": "slot_jackpots", "requirement_value": 25, "reward_amount": 10000, "sort_order": 128}, + {"name": "items_unboxed_100", "title": "100 предметов", "description": "Открой 100 предметов (кейсы/контракты)", "icon": "📦", "category": "items", "requirement_type": "total_items", "requirement_value": 100, "reward_amount": 500, "sort_order": 129}, + {"name": "items_unboxed_1000", "title": "1000 предметов", "description": "Открой 1000 предметов (кейсы/контракты)", "icon": "📦", "category": "items", "requirement_type": "total_items", "requirement_value": 1000, "reward_amount": 5000, "sort_order": 130}, + {"name": "items_unboxed_10000", "title": "10000 предметов", "description": "Открой 10000 предметов (кейсы/контракты)", "icon": "📦", "category": "items", "requirement_type": "total_items", "requirement_value": 10000, "reward_amount": 50000, "sort_order": 131}, + {"name": "total_profit_100k", "title": "Профит 100к", "description": "Получи 100,000 ₽ чистой прибыли", "icon": "💰", "category": "profit", "requirement_type": "total_profit", "requirement_value": 100000, "reward_amount": 1000, "sort_order": 132}, + {"name": "total_profit_1m", "title": "Профит 1 млн", "description": "Получи 1,000,000 ₽ чистой прибыли", "icon": "💰", "category": "profit", "requirement_type": "total_profit", "requirement_value": 1000000, "reward_amount": 5000, "sort_order": 133}, + {"name": "total_profit_10m", "title": "Профит 10 млн", "description": "Получи 10,000,000 ₽ чистой прибыли", "icon": "💰", "category": "profit", "requirement_type": "total_profit", "requirement_value": 10000000, "reward_amount": 25000, "sort_order": 134}, + {"name": "total_profit_100m", "title": "Профит 100 млн", "description": "Получи 100,000,000 ₽ чистой прибыли", "icon": "💰", "category": "profit", "requirement_type": "total_profit", "requirement_value": 100000000, "reward_amount": 100000, "sort_order": 135}, + {"name": "achievements_10", "title": "10 достижений", "description": "Получи 10 достижений", "icon": "🏆", "category": "achievements", "requirement_type": "achievements_count", "requirement_value": 10, "reward_amount": 1000, "sort_order": 136}, + {"name": "achievements_25", "title": "25 достижений", "description": "Получи 25 достижений", "icon": "🏆", "category": "achievements", "requirement_type": "achievements_count", "requirement_value": 25, "reward_amount": 5000, "sort_order": 137}, + {"name": "achievements_50", "title": "50 достижений", "description": "Получи 50 достижений", "icon": "🏆", "category": "achievements", "requirement_type": "achievements_count", "requirement_value": 50, "reward_amount": 25000, "sort_order": 138}, + {"name": "achievements_100", "title": "100 достижений", "description": "Получи 100 достижений", "icon": "🏆", "category": "achievements", "requirement_type": "achievements_count", "requirement_value": 100, "reward_amount": 100000, "sort_order": 139}, + {"name": "rare_item_10", "title": "10 редких предметов", "description": "Собери 10 редких предметов в инвентаре", "icon": "⭐", "category": "items", "requirement_type": "rare_items_count", "requirement_value": 10, "reward_amount": 5000, "sort_order": 140}, + {"name": "rare_item_50", "title": "50 редких предметов", "description": "Собери 50 редких предметов в инвентаре", "icon": "⭐", "category": "items", "requirement_type": "rare_items_count", "requirement_value": 50, "reward_amount": 25000, "sort_order": 141}, + {"name": "knife_5", "title": "Пять ножей", "description": "Собери 5 ножей или перчаток", "icon": "🔪", "category": "items", "requirement_type": "knife_count", "requirement_value": 5, "reward_amount": 25000, "sort_order": 142}, + {"name": "ultra_rare_float", "title": "Ультра-редкий float", "description": "Найди предмет с float < 0.001", "icon": "💎", "category": "items", "requirement_type": "ultra_rare_float", "requirement_value": 1, "reward_amount": 10000, "sort_order": 143, "hidden": True}, + {"name": "inventory_2500", "title": "Инвентарь 2500", "description": "Собери 2500 предметов в инвентаре", "icon": "🎒", "category": "inventory", "requirement_type": "inventory_items", "requirement_value": 2500, "reward_amount": 100000, "sort_order": 144}, + {"name": "upgrade_from_nothing", "title": "Из грязи в князи", "description": "Выиграй апгрейд с предметом стоимостью до 1000 ₽", "icon": "⬆️", "category": "upgrade", "requirement_type": "upgrade_from_nothing", "requirement_value": 1000, "reward_amount": 1000, "sort_order": 145}, + # Секретные + {"name": "secret_99_fail", "title": "99% неудачи", "description": "Провали апгрейд с шансом 99%", "icon": "💀", "category": "secret", "requirement_type": "secret_fail_99", "requirement_value": 1, "reward_amount": 5000, "sort_order": 200, "hidden": True}, + {"name": "secret_midnight", "title": "Полуночник", "description": "Открой кейс в промежутке между 00:00 и 04:00", "icon": "🌙", "category": "secret", "requirement_type": "secret_midnight", "requirement_value": 1, "reward_amount": 2000, "sort_order": 201, "hidden": True}, + {"name": "secret_exact_balance", "title": "Ровно 1337", "description": "Имей ровно 1337 ₽ на балансе", "icon": "💀", "category": "secret", "requirement_type": "secret_exact_balance", "requirement_value": 1, "reward_amount": 1337, "sort_order": 202, "hidden": True}, + {"name": "secret_negative_balance", "title": "В долгах", "description": "Уйди в минус по балансу", "icon": "💀", "category": "secret", "requirement_type": "secret_negative_balance", "requirement_value": 1, "reward_amount": 5000, "sort_order": 203, "hidden": True}, + {"name": "secret_1000_exact", "title": "Ровно 1000", "description": "Открой ровно 1000 кейсов", "icon": "🎯", "category": "secret", "requirement_type": "secret_1000_exact", "requirement_value": 1, "reward_amount": 50000, "sort_order": 204, "hidden": True}, + {"name": "secret_empty_inv", "title": "Пустой инвентарь", "description": "Имей 0 предметов в инвентаре", "icon": "💀", "category": "secret", "requirement_type": "secret_empty_inv", "requirement_value": 1, "reward_amount": 1000, "sort_order": 205, "hidden": True}, + {"name": "secret_spent_exact", "title": "Число зверя", "description": "Потрать ровно 666,666 ₽", "icon": "😈", "category": "secret", "requirement_type": "secret_spent_exact", "requirement_value": 1, "reward_amount": 6666, "sort_order": 206, "hidden": True}, + {"name": "secret_low_prob_win", "title": "Удача новичка", "description": "Выиграй апгрейд с шансом <10% первый раз", "icon": "🎯", "category": "secret", "requirement_type": "secret_low_prob_win", "requirement_value": 1, "reward_amount": 10000, "sort_order": 207, "hidden": True}, + {"name": "secret_dildo_specific", "title": "Конкретный Dildo", "description": "Найди Dildo с названием содержащим 'Big Black'", "icon": "🍆", "category": "secret", "requirement_type": "secret_dildo_specific", "requirement_value": 1, "reward_amount": 10000, "sort_order": 208, "hidden": True}, + {"name": "secret_crash_1x", "title": "Крэш 1x", "description": "Забери деньги на множителе 1.00x", "icon": "🎯", "category": "secret", "requirement_type": "secret_crash_1x", "requirement_value": 1, "reward_amount": 1000, "sort_order": 209, "hidden": True}, + {"name": "secret_balance_777", "title": "777", "description": "Баланс оканчивается на 777", "icon": "🎰", "category": "secret", "requirement_type": "secret_balance_777", "requirement_value": 777, "reward_amount": 7777, "sort_order": 210, "hidden": True}, + {"name": "secret_one_item_inv", "title": "Одиночка", "description": "Имей ровно 1 предмет в инвентаре", "icon": "🎯", "category": "secret", "requirement_type": "secret_one_item_inv", "requirement_value": 1, "reward_amount": 1000, "sort_order": 211, "hidden": True}, + {"name": "secret_contract_dildo", "title": "Случайный Dildo", "description": "Получи Dildo из контракта обмена", "icon": "🍆", "category": "secret", "requirement_type": "secret_contract_dildo", "requirement_value": 1, "reward_amount": 25000, "sort_order": 212, "hidden": True}, + {"name": "secret_slot_zero", "title": "Ноль совпадений", "description": "Получи 0 совпадений в слоте", "icon": "🎰", "category": "secret", "requirement_type": "secret_slot_zero", "requirement_value": 1, "reward_amount": 5000, "sort_order": 213, "hidden": True}, + {"name": "secret_exact_float", "title": "Точный float", "description": "Найди предмет с float ровно 0.001 (±0.0001)", "icon": "🎯", "category": "secret", "requirement_type": "secret_exact_float", "requirement_value": 1, "reward_amount": 50000, "sort_order": 214, "hidden": True}, ] +_DILDO_IDS_CACHE = None -# ─── Функции проверки достижений ────────────────────────────────────────── +def _get_dildo_ids(): + global _DILDO_IDS_CACHE + if _DILDO_IDS_CACHE is None: + from backend import ALL_ITEMS + _DILDO_IDS_CACHE = [i for i, it in enumerate(ALL_ITEMS) if it.get("weapon") == "Dildo"] + return _DILDO_IDS_CACHE -def check_collection_achievements(db: Session, user_id: int): - """Проверяет достижения, связанные с коллекциями — использует кэш.""" - from database import InventoryItem +# ─── async seed ───────────────────────────────────────────────────────────── + +async def seed_achievements(db: AsyncSession): + result = await db.execute(select(sa_func.count()).select_from(Achievement)) + existing = result.scalar() or 0 + if existing > 0: + result = await db.execute(select(Achievement.name)) + existing_names = {row[0] for row in result.all()} + added = 0 + for defn in ACHIEVEMENT_DEFS: + if defn["name"] not in existing_names: + db.add(Achievement(**defn)) + added += 1 + if added: + await db.commit() + print(f"[Achievements] Добавлено {added} новых достижений") + return + for defn in ACHIEVEMENT_DEFS: + db.add(Achievement(**defn)) + await db.commit() + + +async def get_or_create_user_achievement(db: AsyncSession, user_id: int, achievement_id: int) -> UserAchievement: + result = await db.execute( + select(UserAchievement).where( + UserAchievement.user_id == user_id, + UserAchievement.achievement_id == achievement_id + ) + ) + ua = result.scalar_one_or_none() + if not ua: + ua = UserAchievement(user_id=user_id, achievement_id=achievement_id) + db.add(ua) + await db.commit() + await db.refresh(ua) + return ua + + +async def check_achievement(db: AsyncSession, user_id: int, achievement_name: str, current_value: float) -> Optional[dict]: + result = await db.execute(select(Achievement).where(Achievement.name == achievement_name)) + ach = result.scalar_one_or_none() + if not ach: + return None + ua = await get_or_create_user_achievement(db, user_id, ach.id) + if ua.unlocked_at: + return None + progress_val = min(int(current_value), ach.requirement_value) if isinstance(ach.requirement_value, int) else min(current_value, ach.requirement_value) + ua.progress = int(progress_val) if ach.requirement_type not in ("dildo_best_float", "ultra_rare_float", "secret_exact_float") else int(progress_val * 10000) + if current_value >= ach.requirement_value: + ua.unlocked_at = datetime.utcnow() + activity = ActivityFeed( + user_id=user_id, username=f"user_{user_id}", + 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) + await db.flush() + return {"name": ach.name, "title": ach.title, "icon": ach.icon, "reward": ach.reward_amount} + await db.flush() + return None + + +# ─── Проверки (async) ────────────────────────────────────────────────────── + +async def check_open_cases_achievements(db: AsyncSession, user_id: int): + result = await db.execute(select(sa_func.count()).select_from(CaseOpening).where(CaseOpening.user_id == user_id)) + count = result.scalar() or 0 + results = [] + for n in ["first_case", "case_collector_10", "case_collector_100", "case_collector_1000", + "cases_2500", "cases_5000", "cases_10000", "cases_25000"]: + r = await check_achievement(db, user_id, n, count) + if r: results.append(r) + return results + + +async def check_contracts_achievements(db: AsyncSession, user_id: int): + result = await db.execute(select(sa_func.count()).select_from(Contract).where(Contract.user_id == user_id)) + count = result.scalar() or 0 + results = [] + for n in ["first_contract", "contract_master", "contracts_10", "contracts_25", "contracts_100", "contracts_250"]: + r = await check_achievement(db, user_id, n, count) + if r: results.append(r) + return results + + +async def check_upgrade_achievements(db: AsyncSession, user_id: int): + from database import Upgrade + result = await db.execute(select(sa_func.count()).select_from(Upgrade).where(Upgrade.user_id == user_id)) + attempts = result.scalar() or 0 + result = await db.execute(select(sa_func.count()).select_from(Upgrade).where(Upgrade.user_id == user_id, Upgrade.success == True)) + wins = result.scalar() or 0 + results = [] + for n in ["first_upgrade", "upgrade_5", "upgrade_25", "upgrade_50", "upgrade_100", + "upgrade_250", "upgrade_500", "upgrade_1000", "upgrade_2000"]: + r = await check_achievement(db, user_id, n, attempts) + if r: results.append(r) + for n in ["upgrade_winner", "upgrade_wins_5", "upgrade_wins_25", "upgrade_wins_50", + "upgrade_wins_100", "upgrade_wins_250"]: + r = await check_achievement(db, user_id, n, wins) + if r: results.append(r) + from backend import ALL_ITEMS + result = await db.execute(select(Upgrade.input_item_id).where(Upgrade.user_id == user_id, Upgrade.success == False)) + total_spent_val = sum(ALL_ITEMS[iid].get("price_rub", 0) for (iid,) in result.all() if iid < len(ALL_ITEMS)) + for n in ["upgrade_spent_100k", "upgrade_spent_1m", "upgrade_spent_10m", "upgrade_spent_100m"]: + r = await check_achievement(db, user_id, n, total_spent_val) + if r: results.append(r) + return results + + +async def check_upgrade_risk_achievements(db: AsyncSession, user_id: int, item_price: float): + results = [] + for n in ["upgrade_high_risk_50k", "upgrade_high_risk_500k", "upgrade_high_risk_10m"]: + r = await check_achievement(db, user_id, n, item_price) + if r: results.append(r) + return results + + +async def check_upgrade_result_achievements(db: AsyncSession, user_id: int, success: bool, probability: float, item_price: float): + from database import Upgrade + from upgrade import get_upgrade_rpu_async + results = [] + rpu = await get_upgrade_rpu_async(db, user_id) + if rpu.current_win_streak > 0: + for n in ["upgrade_consecutive_wins_3", "upgrade_consecutive_wins_5", "upgrade_consecutive_wins_10"]: + r = await check_achievement(db, user_id, n, rpu.current_win_streak) + if r: results.append(r) + if rpu.current_lose_streak > 0: + for n in ["upgrade_consecutive_losses_5", "upgrade_consecutive_losses_10"]: + r = await check_achievement(db, user_id, n, rpu.current_lose_streak) + if r: results.append(r) + if success and item_price <= 1000: + r = await check_achievement(db, user_id, "upgrade_from_nothing", 1000) + if r: results.append(r) + if success and probability < 10: + result = await db.execute(select(sa_func.count()).select_from(Upgrade).where( + Upgrade.user_id == user_id, Upgrade.success == True, Upgrade.probability < 10 + )) + low_wins = result.scalar() or 0 + for n in ["upgrade_miracle", "upgrade_miracle_5"]: + r = await check_achievement(db, user_id, n, low_wins) + if r: results.append(r) + if not success and probability >= 99: + r = await check_achievement(db, user_id, "secret_99_fail", 1) + if r: results.append(r) + if success and probability < 10: + r = await check_achievement(db, user_id, "secret_low_prob_win", 1) + if r: results.append(r) + return results + + +async def check_rare_item_achievement(db: AsyncSession, user_id: int, rarity: str): + if rarity in ("Covert", "Extraordinary", "Rare Special Item"): + r = await check_achievement(db, user_id, "rare_finder", 1) + return [r] if r else [] + return [] + + +async def check_knife_achievement(db: AsyncSession, user_id: int, item_data: dict): + from backend import is_knife, is_gloves + if is_knife(item_data) or is_gloves(item_data): + r = await check_achievement(db, user_id, "knife_collector", 1) + return [r] if r else [] + return [] + + +async def check_spent_achievements(db: AsyncSession, user_id: int, total_spent: float): + results = [] + for n in ["big_spender", "whale", "spent_500k", "spent_1m", "spent_10m", + "spent_100m", "spent_500m", "spent_1b"]: + r = await check_achievement(db, user_id, n, int(total_spent)) + if r: results.append(r) + if int(total_spent) == 666666: + r = await check_achievement(db, user_id, "secret_spent_exact", 1) + if r: results.append(r) + return results + + +async def check_balance_achievements(db: AsyncSession, user_id: int): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + return [] + results = [] + if user.balance >= 100000: + r = await check_achievement(db, user_id, "rich_100k", 1) + if r: results.append(r) + for n in ["balance_1m", "balance_10m", "balance_100m", "balance_1b"]: + r = await check_achievement(db, user_id, n, user.balance) + if r: results.append(r) + if user.balance == 1337: + r = await check_achievement(db, user_id, "secret_exact_balance", 1) + if r: results.append(r) + if user.balance > 0 and user.balance % 1000 == 777: + r = await check_achievement(db, user_id, "secret_balance_777", 777) + if r: results.append(r) + if user.balance < 0: + r = await check_achievement(db, user_id, "secret_negative_balance", 1) + if r: results.append(r) + return results + + +async def check_slot_match_achievements(db: AsyncSession, user_id: int): + r = await check_achievement(db, user_id, "slot_winner", 1) + return [r] if r else [] + + +async def check_slot_plays_achievements(db: AsyncSession, user_id: int): + result = await db.execute( + select(sa_func.count()).select_from(CaseOpening).where( + CaseOpening.user_id == user_id, CaseOpening.case_name.ilike("%slot%") + ) + ) + count = result.scalar() or 0 + results = [] + for n in ["slots_10", "slots_25", "slots_100", "slots_500"]: + r = await check_achievement(db, user_id, n, count) + if r: results.append(r) + return results + + +async def check_slot_match_count_achievements(db: AsyncSession, user_id: int): + result = await db.execute( + select(sa_func.count()).select_from(InventoryItem).where( + InventoryItem.user_id == user_id, InventoryItem.obtained_from.ilike("slot_bonus:%") + ) + ) + count = result.scalar() or 0 + results = [] + for n in ["slots_jackpot_5", "slots_jackpot_25"]: + r = await check_achievement(db, user_id, n, count) + if r: results.append(r) + return results + + +async def check_inventory_achievements(db: AsyncSession, user_id: int): + result = await db.execute(select(sa_func.count()).select_from(InventoryItem).where(InventoryItem.user_id == user_id)) + count = result.scalar() or 0 + results = [] + for n in ["inventory_50", "inventory_100", "inventory_250", "inventory_500", + "inventory_1000", "inventory_2500"]: + r = await check_achievement(db, user_id, n, count) + if r: results.append(r) + if count == 0: + r = await check_achievement(db, user_id, "secret_empty_inv", 1) + if r: results.append(r) + if count == 1: + r = await check_achievement(db, user_id, "secret_one_item_inv", 1) + if r: results.append(r) + return results + + +async def check_dildo_achievements(db: AsyncSession, user_id: int): + from backend import ALL_ITEMS + dildo_ids = _get_dildo_ids() + results = [] + if dildo_ids: + result = await db.execute( + select(InventoryItem).where( + InventoryItem.user_id == user_id, + InventoryItem.item_id.in_(dildo_ids) + ) + ) + dildo_inv_raw = result.scalars().all() + else: + dildo_inv_raw = [] + dildo_inv = [(inv, ALL_ITEMS[inv.item_id]) for inv in dildo_inv_raw if inv.item_id < len(ALL_ITEMS)] + dildo_count = len(dildo_inv) + for n in ["dildo_owned", "dildo_5", "dildo_10", "dildo_25", "dildo_50", "dildo_100"]: + r = await check_achievement(db, user_id, n, dildo_count) + if r: results.append(r) + + result = await db.execute( + select(sa_func.max(InventoryItem.price_rub)).where(InventoryItem.user_id == user_id) + ) + best_price = result.scalar() or 0 + for n in ["item_worth_1m", "item_worth_10m", "item_worth_100m", "item_worth_1b"]: + r = await check_achievement(db, user_id, n, best_price) + if r: results.append(r) + + result = await db.execute( + select(sa_func.sum(InventoryItem.price_rub)).where(InventoryItem.user_id == user_id) + ) + total_val = result.scalar() or 0 + for n in ["inventory_value_10m", "inventory_value_100m", "inventory_value_1b"]: + r = await check_achievement(db, user_id, n, total_val) + if r: results.append(r) + + if dildo_ids: + result = await db.execute( + select(sa_func.min(InventoryItem.float_value)).where( + InventoryItem.user_id == user_id, + InventoryItem.item_id.in_(dildo_ids) + ) + ) + best_dildo_float = result.scalar() or 1.0 + else: + best_dildo_float = 1.0 + + if best_dildo_float < 0.01: + r = await check_achievement(db, user_id, "dildo_float_001", 1) + if r: results.append(r) + if best_dildo_float < 0.005: + r = await check_achievement(db, user_id, "dildo_float_0005", 2) + if r: results.append(r) + + result = await db.execute( + select(sa_func.min(InventoryItem.float_value)).where(InventoryItem.user_id == user_id) + ) + ultra_rare = result.scalar() or 1.0 + if ultra_rare < 0.001: + r = await check_achievement(db, user_id, "ultra_rare_float", 0.001) + if r: results.append(r) + + result = await db.execute( + select(InventoryItem).where( + InventoryItem.user_id == user_id, + sa_func.abs(InventoryItem.float_value - 0.001) < 0.0001 + ).limit(1) + ) + if result.scalar_one_or_none(): + r = await check_achievement(db, user_id, "secret_exact_float", 1) + if r: results.append(r) + + if dildo_inv: + dildo_rarities = set() + unique_patterns = set() + dildo_total = 0 + for inv_item, item_data in dildo_inv: + dildo_rarities.add(item_data.get("rarity")) + unique_patterns.add(item_data.get("market_hash_name", "")) + dildo_total += item_data.get("price_rub", 0) + if len(dildo_rarities) >= 5: + r = await check_achievement(db, user_id, "dildo_all_rarities", 1) + if r: results.append(r) + r = await check_achievement(db, user_id, "dildo_full_set", len(unique_patterns)) + if r: results.append(r) + r = await check_achievement(db, user_id, "dildo_worth_100m", dildo_total) + if r: results.append(r) + for inv_item, item_data in dildo_inv: + if "Big Black" in item_data.get("market_hash_name", ""): + r = await check_achievement(db, user_id, "secret_dildo_specific", 1) + if r: results.append(r) + break + return results + + +async def check_dildo_contract_achievement(db: AsyncSession, user_id: int): + from backend import ALL_ITEMS + result = await db.execute(select(Contract).where(Contract.user_id == user_id)) + contracts = result.scalars().all() + results = [] + for c in contracts: + if c.output_item_id < len(ALL_ITEMS) and ALL_ITEMS[c.output_item_id].get("weapon") == "Dildo": + r = await check_achievement(db, user_id, "dildo_from_contract", 1) + if r: results.append(r) + break + for c in contracts: + if c.output_item_id < len(ALL_ITEMS) and ALL_ITEMS[c.output_item_id].get("weapon") == "Dildo": + r = await check_achievement(db, user_id, "secret_contract_dildo", 1) + if r: results.append(r) + break + return results + + +async def check_crash_achievements(db: AsyncSession, user_id: int, crash_count: int): + results = [] + for n in ["crash_first", "crash_pro", "crash_10", "crash_25", "crash_100", + "crash_500", "crash_1000", "crash_5000"]: + r = await check_achievement(db, user_id, n, crash_count) + if r: results.append(r) + return results + + +async def check_crash_cashout_achievements(db: AsyncSession, user_id: int, multiplier: float): + results = [] + for n in ["crash_5x", "crash_cashout_10x", "crash_cashout_25x", "crash_cashout_50x", "crash_cashout_100x"]: + req_val = {"crash_5x": 5, "crash_cashout_10x": 10, "crash_cashout_25x": 25, + "crash_cashout_50x": 50, "crash_cashout_100x": 100}[n] + if multiplier >= req_val: + r = await check_achievement(db, user_id, n, multiplier) + if r: results.append(r) + if abs(multiplier - 1.0) < 0.01: + r = await check_achievement(db, user_id, "secret_crash_1x", 1) + if r: results.append(r) + return results + + +async def check_items_unboxed_achievements(db: AsyncSession, user_id: int): + result = await db.execute(select(sa_func.count()).select_from(CaseOpening).where(CaseOpening.user_id == user_id)) + count = result.scalar() or 0 + results = [] + for n in ["items_unboxed_100", "items_unboxed_1000", "items_unboxed_10000"]: + r = await check_achievement(db, user_id, n, count) + if r: results.append(r) + return results + + +async def check_total_profit_achievements(db: AsyncSession, user_id: int, profit: float): + results = [] + for n in ["total_profit_100k", "total_profit_1m", "total_profit_10m", "total_profit_100m"]: + r = await check_achievement(db, user_id, n, profit) + if r: results.append(r) + return results + + +async def check_achievements_count_achievements(db: AsyncSession, user_id: int): + result = await db.execute( + select(sa_func.count()).select_from(UserAchievement).where( + UserAchievement.user_id == user_id, + UserAchievement.unlocked_at.isnot(None) + ) + ) + count = result.scalar() or 0 + results = [] + for n in ["achievements_10", "achievements_25", "achievements_50", "achievements_100"]: + r = await check_achievement(db, user_id, n, count) + if r: results.append(r) + return results + + +async def check_rare_item_count_achievements(db: AsyncSession, user_id: int): + result = await db.execute( + select(sa_func.count()).select_from(InventoryItem).where( + InventoryItem.user_id == user_id, + InventoryItem.rarity.in_(["Covert", "Extraordinary", "Rare Special Item"]) + ) + ) + count = result.scalar() or 0 + results = [] + for n in ["rare_item_10", "rare_item_50"]: + r = await check_achievement(db, user_id, n, count) + if r: results.append(r) + return results + + +async def check_knife_count_achievements(db: AsyncSession, user_id: int): + from backend import ALL_ITEMS, is_knife, is_gloves + result = await db.execute(select(InventoryItem.item_id).where(InventoryItem.user_id == user_id)) + knife_count = 0 + for (item_id,) in result.all(): + if item_id < len(ALL_ITEMS): + item_data = ALL_ITEMS[item_id] + if is_knife(item_data) or is_gloves(item_data): + knife_count += 1 + results = [] + for n in ["knife_5"]: + r = await check_achievement(db, user_id, n, knife_count) + if r: results.append(r) + return results + + +async def check_midnight_case_achievement(db: AsyncSession, user_id: int) -> list: + from sqlalchemy import func as sf + result = await db.execute( + select(sa_func.count()).select_from(CaseOpening).where( + CaseOpening.user_id == user_id, + sf.extract('hour', CaseOpening.opened_at).between(0, 4) + ) + ) + if (result.scalar() or 0) > 0: + r = await check_achievement(db, user_id, "secret_midnight", 1) + return [r] if r else [] + return [] + + +async def check_exact_cases_achievement(db: AsyncSession, user_id: int, case_count: int) -> list: + r = await check_achievement(db, user_id, "secret_1000_exact", 1 if case_count == 1000 else 0) + return [r] if r else [] + + +async def check_collection_achievements(db: AsyncSession, user_id: int): _build_collection_cache() cache = _COLLECTION_CACHE coll_base_names = cache["base_names"] @@ -1849,12 +689,9 @@ def check_collection_achievements(db: Session, user_id: int): coll_is_custom = cache["is_custom"] results = [] - # ID предметов пользователя - user_ids = {iid for (iid,) in db.query(InventoryItem.item_id).filter( - InventoryItem.user_id == user_id - ).all()} + result = await db.execute(select(InventoryItem.item_id).where(InventoryItem.user_id == user_id)) + user_ids = {row[0] for row in result.all()} - # Коллекции пользователя from backend import ALL_ITEMS, COLLECTIONS_INDEX, extract_base_name, RARITY_ORDER user_coll_base_names = {} for coll_name, item_ids_set in coll_item_ids.items(): @@ -1871,7 +708,7 @@ def check_collection_achievements(db: Session, user_id: int): coll_count = len(user_coll_base_names) for n in ["coll_first", "coll_5", "coll_10", "coll_25", "coll_50", "coll_75", "coll_100"]: - r = check_achievement(db, user_id, n, coll_count) + r = await check_achievement(db, user_id, n, coll_count) if r: results.append(r) all_owned_bases = set() @@ -1880,7 +717,7 @@ def check_collection_achievements(db: Session, user_id: int): total_unique = len(all_owned_bases) for n in ["coll_items_10", "coll_items_50", "coll_items_100", "coll_items_250", "coll_items_500", "coll_items_1000"]: - r = check_achievement(db, user_id, n, total_unique) + r = await check_achievement(db, user_id, n, total_unique) if r: results.append(r) complete_count = 0 @@ -1888,32 +725,24 @@ def check_collection_achievements(db: Session, user_id: int): owned = user_coll_base_names.get(coll_name, set()) if all_bases.issubset(owned): complete_count += 1 - for n in ["coll_complete_1", "coll_complete_5", "coll_complete_10", "coll_complete_25", - "coll_complete_50"]: - r = check_achievement(db, user_id, n, complete_count) + for n in ["coll_complete_1", "coll_complete_5", "coll_complete_10", "coll_complete_25", "coll_complete_50"]: + r = await check_achievement(db, user_id, n, complete_count) if r: results.append(r) non_custom = {k: v for k, v in coll_base_names.items() if not coll_is_custom.get(k)} - all_base_ok = all( - non_custom[c].issubset(user_coll_base_names.get(c, set())) - for c in non_custom - ) + all_base_ok = all(non_custom[c].issubset(user_coll_base_names.get(c, set())) for c in non_custom) if all_base_ok and non_custom: - r = check_achievement(db, user_id, "coll_all_base", 1) + r = await check_achievement(db, user_id, "coll_all_base", 1) if r: results.append(r) - all_ok = all( - coll_base_names[c].issubset(user_coll_base_names.get(c, set())) - for c in coll_base_names - ) + all_ok = all(coll_base_names[c].issubset(user_coll_base_names.get(c, set())) for c in coll_base_names) if all_ok and coll_base_names: - r = check_achievement(db, user_id, "coll_all", 1) + r = await check_achievement(db, user_id, "coll_all", 1) if r: results.append(r) rarity_collectors = [ - ("coll_cheap_collector", {0}), - ("coll_mid_collector", {2}), - ("coll_rare_collector", {3, 4, 5, 6, 7, 8}), + ("coll_cheap_collector", {0}), ("coll_mid_collector", {2}), + ("coll_rare_collector", {3, 4, 5, 6, 7, 8}), ("coll_covert_collector", {5}), ] for ach_name, target_levels in rarity_collectors: @@ -1938,640 +767,51 @@ def check_collection_achievements(db: Session, user_id: int): found = True break if found: - r = check_achievement(db, user_id, ach_name, 1) + r = await check_achievement(db, user_id, ach_name, 1) if r: results.append(r) for coll_name in coll_base_names: if coll_name.startswith("Case:"): owned = user_coll_base_names.get(coll_name, set()) if coll_base_names[coll_name].issubset(owned): - r = check_achievement(db, user_id, "coll_full_case", 1) + r = await check_achievement(db, user_id, "coll_full_case", 1) if r: results.append(r) break return results -def seed_achievements(db: Session): - existing = db.query(Achievement).count() - if existing > 0: - # Добавляем только новые (отсутствующие в БД) - existing_names = {a.name for a in db.query(Achievement.name).all()} - added = 0 - for defn in ACHIEVEMENT_DEFS: - if defn["name"] not in existing_names: - ach = Achievement(**defn) - db.add(ach) - added += 1 - if added: - db.commit() - print(f"[Achievements] Добавлено {added} новых достижений") - 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: float) -> 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 - - progress_val = min(int(current_value), ach.requirement_value) if isinstance(ach.requirement_value, int) else min(current_value, ach.requirement_value) - ua.progress = int(progress_val) if ach.requirement_type not in ("dildo_best_float", "ultra_rare_float", "secret_exact_float") else int(progress_val * 10000) - - if current_value >= ach.requirement_value: - ua.unlocked_at = datetime.utcnow() - - activity = ActivityFeed( - user_id=user_id, - username=f"user_{user_id}", - 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.flush() - - return { - "name": ach.name, - "title": ach.title, - "icon": ach.icon, - "reward": ach.reward_amount - } - - db.flush() - 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() - names = ["first_case", "case_collector_10", "case_collector_100", "case_collector_1000", - "cases_2500", "cases_5000", "cases_10000", "cases_25000"] - results = [] - for n in names: - r = check_achievement(db, user_id, n, 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() - names = ["first_contract", "contract_master", "contracts_10", "contracts_25", "contracts_100", "contracts_250"] - results = [] - for n in names: - r = check_achievement(db, user_id, n, 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_count = db.query(Upgrade).filter(Upgrade.user_id == user_id, Upgrade.success == True).count() - - results = [] - # upgrade_attempts based - for n in ["first_upgrade", "upgrade_5", "upgrade_25", "upgrade_50", "upgrade_100", - "upgrade_250", "upgrade_500", "upgrade_1000", "upgrade_2000"]: - r = check_achievement(db, user_id, n, attempts) - if r: results.append(r) - # upgrade_wins based - for n in ["upgrade_winner", "upgrade_wins_5", "upgrade_wins_25", "upgrade_wins_50", - "upgrade_wins_100", "upgrade_wins_250"]: - r = check_achievement(db, user_id, n, wins_count) - if r: results.append(r) - - # Upgrade total spent value (look up price from ALL_ITEMS for failed upgrades) - from backend import ALL_ITEMS - failed_upgrades = db.query(Upgrade.input_item_id).filter( - Upgrade.user_id == user_id, Upgrade.success == False - ).all() - total_spent_val = 0 - for (iid,) in failed_upgrades: - if iid < len(ALL_ITEMS): - total_spent_val += ALL_ITEMS[iid].get("price_rub", 0) - for n in ["upgrade_spent_100k", "upgrade_spent_1m", "upgrade_spent_10m", "upgrade_spent_100m"]: - r = check_achievement(db, user_id, n, total_spent_val) - if r: results.append(r) - - return results - - -def check_upgrade_risk_achievements(db: Session, user_id: int, item_price: float): - """Вызывается при каждой попытке апгрейда — проверяет рискованные ставки""" - results = [] - for n in ["upgrade_high_risk_50k", "upgrade_high_risk_500k", "upgrade_high_risk_10m"]: - r = check_achievement(db, user_id, n, item_price) - if r: results.append(r) - return results - - -def check_upgrade_result_achievements(db: Session, user_id: int, success: bool, probability: float, item_price: float): - """Вызывается после завершения апгрейда — проверяет серии, чудеса и т.д.""" - from database import Upgrade - from upgrade import get_upgrade_rpu - results = [] - - # Используем UpgradeRPU.current_win_streak / current_lose_streak вместо загрузки всех апгрейдов - rpu = get_upgrade_rpu(db, user_id) - - if rpu.current_win_streak > 0: - for n in ["upgrade_consecutive_wins_3", "upgrade_consecutive_wins_5", "upgrade_consecutive_wins_10"]: - r = check_achievement(db, user_id, n, rpu.current_win_streak) - if r: results.append(r) - if rpu.current_lose_streak > 0: - for n in ["upgrade_consecutive_losses_5", "upgrade_consecutive_losses_10"]: - r = check_achievement(db, user_id, n, rpu.current_lose_streak) - if r: results.append(r) - - # Low value win - if success and item_price <= 1000: - r = check_achievement(db, user_id, "upgrade_from_nothing", 1000) - if r: results.append(r) - - # Low probability win - if success and probability < 10: - low_prob_wins = db.query(Upgrade).filter( - Upgrade.user_id == user_id, Upgrade.success == True, Upgrade.probability < 10 - ).count() - for n in ["upgrade_miracle", "upgrade_miracle_5"]: - r = check_achievement(db, user_id, n, low_prob_wins) - if r: results.append(r) - - # Secret: 99% fail - if not success and probability >= 99: - r = check_achievement(db, user_id, "secret_99_fail", 1) - if r: results.append(r) - - # Secret: low prob win - if success and probability < 10: - r = check_achievement(db, user_id, "secret_low_prob_win", 1) - if r: results.append(r) - - 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): - names = ["big_spender", "whale", "spent_500k", "spent_1m", "spent_10m", - "spent_100m", "spent_500m", "spent_1b"] - results = [] - for n in names: - r = check_achievement(db, user_id, n, int(total_spent)) - if r: results.append(r) - - # Secret: exact spent 666666 - if int(total_spent) == 666666: - r = check_achievement(db, user_id, "secret_spent_exact", 1) - if r: results.append(r) - - return results - - -def check_balance_achievements(db: Session, user_id: int): - from database import User - user = db.query(User).filter(User.id == user_id).first() - if not user: - return [] - results = [] - # rich_100k legacy — DB has requirement_value=1, pass flag when condition met - if user.balance >= 100000: - r = check_achievement(db, user_id, "rich_100k", 1) - if r: results.append(r) - for n in ["balance_1m", "balance_10m", "balance_100m", "balance_1b"]: - r = check_achievement(db, user_id, n, user.balance) - if r: results.append(r) - - # Secret: exact balance 1337 - if user.balance == 1337: - r = check_achievement(db, user_id, "secret_exact_balance", 1) - if r: results.append(r) - - # Secret: balance % 1000 == 777 - if user.balance > 0 and user.balance % 1000 == 777: - r = check_achievement(db, user_id, "secret_balance_777", 777) - if r: results.append(r) - - # Secret: negative balance - if user.balance < 0: - r = check_achievement(db, user_id, "secret_negative_balance", 1) - if r: results.append(r) - - return results - - -def check_slot_match_achievements(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() - names = ["slots_10", "slots_25", "slots_100", "slots_500"] - results = [] - for n in names: - r = check_achievement(db, user_id, n, slot_count) - if r: results.append(r) - return results - - -def check_slot_match_count_achievements(db: Session, user_id: int): - """Проверяет количество совпадений в слотах (считаем по бонусным предметам)""" - from database import InventoryItem - matches = db.query(InventoryItem).filter( - InventoryItem.user_id == user_id, - InventoryItem.obtained_from.ilike("slot_bonus:%") - ).count() - results = [] - for n in ["slots_jackpot_5", "slots_jackpot_25"]: - r = check_achievement(db, user_id, n, matches) - if r: results.append(r) - return results - - -def check_inventory_achievements(db: Session, user_id: int): - from database import InventoryItem - count = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).count() - names = ["inventory_50", "inventory_100", "inventory_250", "inventory_500", - "inventory_1000", "inventory_2500"] - results = [] - for n in names: - r = check_achievement(db, user_id, n, count) - if r: results.append(r) - - # Secret: empty inventory - if count == 0: - r = check_achievement(db, user_id, "secret_empty_inv", 1) - if r: results.append(r) - - # Secret: exactly 1 item - if count == 1: - r = check_achievement(db, user_id, "secret_one_item_inv", 1) - if r: results.append(r) - - return results - - -# Кэш ID дилдоков (строится один раз через импорт) -_DILDO_IDS_CACHE = None - -def _get_dildo_ids(): - global _DILDO_IDS_CACHE - if _DILDO_IDS_CACHE is None: - from backend import ALL_ITEMS - _DILDO_IDS_CACHE = [i for i, it in enumerate(ALL_ITEMS) if it.get("weapon") == "Dildo"] - return _DILDO_IDS_CACHE - - -def check_dildo_achievements(db: Session, user_id: int): - """Проверяет все Dildo-связанные достижения — инвентарь грузит только IN-запросом.""" - from database import InventoryItem - from backend import ALL_ITEMS - from sqlalchemy import func as sa_func - results = [] - - # Загружаем только dildo-предметы через IN-запрос - dildo_ids = _get_dildo_ids() - if not dildo_ids: - dildo_inv = [] - else: - dildo_inv_raw = db.query(InventoryItem).filter( - InventoryItem.user_id == user_id, - InventoryItem.item_id.in_(dildo_ids) - ).all() - dildo_inv = [(inv, ALL_ITEMS[inv.item_id]) for inv in dildo_inv_raw - if inv.item_id < len(ALL_ITEMS)] - - dildo_count = len(dildo_inv) - - # Dildo count achievements - for n in ["dildo_owned", "dildo_5", "dildo_10", "dildo_25", "dildo_50", "dildo_100"]: - r = check_achievement(db, user_id, n, dildo_count) - if r: results.append(r) - - # Best price, total value — через aggregates - best_price = db.query(sa_func.max(InventoryItem.price_rub)).filter( - InventoryItem.user_id == user_id - ).scalar() or 0 - for n in ["item_worth_1m", "item_worth_10m", "item_worth_100m", "item_worth_1b"]: - r = check_achievement(db, user_id, n, best_price) - if r: results.append(r) - - total_val = db.query(sa_func.sum(InventoryItem.price_rub)).filter( - InventoryItem.user_id == user_id - ).scalar() or 0 - for n in ["inventory_value_10m", "inventory_value_100m", "inventory_value_1b"]: - r = check_achievement(db, user_id, n, total_val) - if r: results.append(r) - - # Float achievements - best_dildo_float = db.query(sa_func.min(InventoryItem.float_value)).filter( - InventoryItem.user_id == user_id, - InventoryItem.item_id < len(ALL_ITEMS) - ).filter( - InventoryItem.item_id.in_([i for i, d in enumerate(ALL_ITEMS) if d.get("weapon") == "Dildo"]) - ).scalar() or 1.0 - if best_dildo_float < 0.01: - r = check_achievement(db, user_id, "dildo_float_001", 1) - if r: results.append(r) - if best_dildo_float < 0.005: - r = check_achievement(db, user_id, "dildo_float_0005", 2) - if r: results.append(r) - - # Ultra rare float (any item) - ultra_rare = db.query(sa_func.min(InventoryItem.float_value)).filter( - InventoryItem.user_id == user_id - ).scalar() or 1.0 - if ultra_rare < 0.001: - r = check_achievement(db, user_id, "ultra_rare_float", 0.001) - if r: results.append(r) - - # Secret float - secret_float = db.query(InventoryItem).filter( - InventoryItem.user_id == user_id, - sa_func.abs(InventoryItem.float_value - 0.001) < 0.0001 - ).first() - if secret_float: - r = check_achievement(db, user_id, "secret_exact_float", 1) - if r: results.append(r) - - # Dildo rarities & unique patterns & total value — из dildo_inv (уже загружено) - if dildo_inv: - dildo_rarities = set() - unique_patterns = set() - dildo_total = 0 - for inv_item, item_data in dildo_inv: - dildo_rarities.add(item_data.get("rarity")) - unique_patterns.add(item_data.get("market_hash_name", "")) - dildo_total += item_data.get("price_rub", 0) - - if len(dildo_rarities) >= 5: - r = check_achievement(db, user_id, "dildo_all_rarities", 1) - if r: results.append(r) - - r = check_achievement(db, user_id, "dildo_full_set", len(unique_patterns)) - if r: results.append(r) - - r = check_achievement(db, user_id, "dildo_worth_100m", dildo_total) - if r: results.append(r) - - # Secret dildo name - for inv_item, item_data in dildo_inv: - if "Big Black" in item_data.get("market_hash_name", ""): - r = check_achievement(db, user_id, "secret_dildo_specific", 1) - if r: results.append(r) - break - - return results - - -def check_dildo_contract_achievement(db: Session, user_id: int): - """Проверяет получение Dildo из контракта""" - from database import Contract, InventoryItem - results = [] - - # Check if any contract output is a Dildo - from backend import ALL_ITEMS - contracts = db.query(Contract).filter(Contract.user_id == user_id).all() - for c in contracts: - if c.output_item_id < len(ALL_ITEMS): - item_data = ALL_ITEMS[c.output_item_id] - if item_data.get("weapon") == "Dildo": - r = check_achievement(db, user_id, "dildo_from_contract", 1) - if r: results.append(r) - break - - # Secret: contract gave dildo - for c in contracts: - if c.output_item_id < len(ALL_ITEMS): - item_data = ALL_ITEMS[c.output_item_id] - if item_data.get("weapon") == "Dildo": - r = check_achievement(db, user_id, "secret_contract_dildo", 1) - if r: results.append(r) - break - - return results - - -def check_crash_achievements(db: Session, user_id: int, crash_count: int): - from database import ActivityFeed as CrActivity - results = [] - for n in ["crash_first", "crash_pro", "crash_10", "crash_25", "crash_100", - "crash_500", "crash_1000", "crash_5000"]: - r = check_achievement(db, user_id, n, crash_count) - if r: results.append(r) - return results - - -def check_crash_cashout_achievements(db: Session, user_id: int, multiplier: float): - results = [] - for n in ["crash_5x", "crash_cashout_10x", "crash_cashout_25x", "crash_cashout_50x", "crash_cashout_100x"]: - req_val = {"crash_5x": 5, "crash_cashout_10x": 10, "crash_cashout_25x": 25, - "crash_cashout_50x": 50, "crash_cashout_100x": 100}[n] - if multiplier >= req_val: - r = check_achievement(db, user_id, n, multiplier) - if r: results.append(r) - - # Secret: crash 1x - if abs(multiplier - 1.0) < 0.01: - r = check_achievement(db, user_id, "secret_crash_1x", 1) - if r: results.append(r) - - return results - - -def check_items_unboxed_achievements(db: Session, user_id: int): - from database import CaseOpening - count = db.query(CaseOpening).filter(CaseOpening.user_id == user_id).count() - results = [] - for n in ["items_unboxed_100", "items_unboxed_1000", "items_unboxed_10000"]: - r = check_achievement(db, user_id, n, count) - if r: results.append(r) - return results - - -def check_total_profit_achievements(db: Session, user_id: int, profit: float): - results = [] - for n in ["total_profit_100k", "total_profit_1m", "total_profit_10m", "total_profit_100m"]: - r = check_achievement(db, user_id, n, profit) - if r: results.append(r) - return results - - -def check_achievements_count_achievements(db: Session, user_id: int): - count = db.query(UserAchievement).filter( - UserAchievement.user_id == user_id, - UserAchievement.unlocked_at.isnot(None) - ).count() - results = [] - for n in ["achievements_10", "achievements_25", "achievements_50", "achievements_100"]: - r = check_achievement(db, user_id, n, count) - if r: results.append(r) - return results - - -def check_rare_item_count_achievements(db: Session, user_id: int): - from database import InventoryItem - rare_count = db.query(InventoryItem).filter( - InventoryItem.user_id == user_id, - InventoryItem.rarity.in_(["Covert", "Extraordinary", "Rare Special Item"]) - ).count() - results = [] - for n in ["rare_item_10", "rare_item_50"]: - r = check_achievement(db, user_id, n, rare_count) - if r: results.append(r) - return results - - -def check_knife_count_achievements(db: Session, user_id: int): - from database import InventoryItem - from backend import ALL_ITEMS, is_knife, is_gloves - knife_count = 0 - items = db.query(InventoryItem.item_id).filter(InventoryItem.user_id == user_id).all() - for (item_id,) in items: - if item_id < len(ALL_ITEMS): - item_data = ALL_ITEMS[item_id] - if is_knife(item_data) or is_gloves(item_data): - knife_count += 1 - results = [] - for n in ["knife_5"]: - r = check_achievement(db, user_id, n, knife_count) - if r: results.append(r) - return results - - -def check_midnight_case_achievement(db: Session, user_id: int) -> list: - """Проверяет секретное достижение 'открытие кейса в полночь'""" - from database import CaseOpening - from sqlalchemy import func - midnight_openings = db.query(CaseOpening).filter( - CaseOpening.user_id == user_id, - func.strftime("%H", CaseOpening.opened_at).between("00", "04") - ).count() - if midnight_openings > 0: - r = check_achievement(db, user_id, "secret_midnight", 1) - return [r] if r else [] +async def check_all_secret_achievements(db: AsyncSession, user_id: int): return [] -def check_exact_cases_achievement(db: Session, user_id: int, case_count: int) -> list: - """Проверяет секретное достижение 'ровно 1000 кейсов'""" - result = check_achievement(db, user_id, "secret_1000_exact", 1 if case_count == 1000 else 0) - return [result] if result else [] - - -def check_all_secret_achievements(db: Session, user_id: int): - """Проверяет все секретные достижения (вызывается периодически)""" - results = [] - # These are checked in their respective functions above: - # secret_midnight - check_midnight_case_achievement - # secret_exact_balance - check_balance_achievements - # secret_99_fail - check_upgrade_result_achievements - # secret_negative_balance - check_balance_achievements - # secret_1000_exact - check_exact_cases_achievement - # secret_empty_inv - check_inventory_achievements - # secret_spent_exact - check_spent_achievements - # secret_low_prob_win - check_upgrade_result_achievements - # secret_dildo_specific - check_dildo_achievements - # secret_crash_1x - check_crash_cashout_achievements - # secret_balance_777 - check_balance_achievements - # secret_one_item_inv - check_inventory_achievements - # secret_contract_dildo - check_dildo_contract_achievement - # secret_slot_zero - need to implement - # secret_exact_float - check_dildo_achievements - return results - - -# ─── Получение достижений пользователя ───────────────────────────────────── - -def get_user_achievements(db: Session, user_id: int) -> list: - """Возвращает все достижения пользователя с прогрессом. - Секретные (hidden) достижения показываются как ??? пока не открыты.""" - all_ach = db.query(Achievement).order_by(Achievement.sort_order).all() - result = [] +async def get_user_achievements(db: AsyncSession, user_id: int) -> list: + result = await db.execute(select(Achievement).order_by(Achievement.sort_order)) + all_ach = result.scalars().all() + result_list = [] for ach in all_ach: - ua = db.query(UserAchievement).filter( - UserAchievement.user_id == user_id, - UserAchievement.achievement_id == ach.id - ).first() + result_ua = await db.execute( + select(UserAchievement).where( + UserAchievement.user_id == user_id, + UserAchievement.achievement_id == ach.id + ) + ) + ua = result_ua.scalar_one_or_none() unlocked = ua.unlocked_at is not None if ua else False - - # Секретные: скрываем название/описание пока не открыто if ach.hidden and not unlocked: - result.append({ - "id": ach.id, - "name": ach.name, - "title": "???", - "description": "Хммм... похоже что я не знаю как его открыть", - "icon": "❓", - "category": ach.category, + result_list.append({ + "id": ach.id, "name": ach.name, + "title": "???", "description": "Хммм... похоже что я не знаю как его открыть", + "icon": "❓", "category": ach.category, "requirement_type": ach.requirement_type, "requirement_value": ach.requirement_value, "reward_amount": ach.reward_amount, - "progress": 0, - "unlocked": False, - "unlocked_at": None, - "hidden": True, + "progress": 0, "unlocked": False, "unlocked_at": None, "hidden": True, }) else: - result.append({ - "id": ach.id, - "name": ach.name, - "title": ach.title, - "description": ach.description, - "icon": ach.icon, - "category": ach.category, + result_list.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, @@ -2580,4 +820,4 @@ def get_user_achievements(db: Session, user_id: int) -> list: "unlocked_at": ua.unlocked_at.isoformat() if ua and ua.unlocked_at else None, "hidden": ach.hidden if hasattr(ach, 'hidden') else False, }) - return result + return result_list diff --git a/auth.py b/auth.py index d964aa3..7657516 100644 --- a/auth.py +++ b/auth.py @@ -3,127 +3,135 @@ from typing import Optional from jose import JWTError, jwt from passlib.context import CryptContext from fastapi import Depends, HTTPException, status, Request -from fastapi.security import OAuth2PasswordBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from database import get_db, User +from database import get_async_db, get_db, User import os -SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production") +SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey-change-in-production") ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) + def decode_token(token: str) -> Optional[dict]: - """Декодирует JWT токен""" - if not token: # <-- Добавлена проверка + if not token: return None try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return payload + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except JWTError: return None + def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) + def get_password_hash(password: str) -> str: return pwd_context.hash(password) + def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) -async def get_current_user_from_token(token: str, db: Session) -> Optional[User]: - """Получает пользователя из JWT токена""" - if not token: # <-- Добавлена проверка + +async def get_current_user_from_token(token: str, db) -> Optional[User]: + if not token: return None payload = decode_token(token) if not payload: return None - username = payload.get("sub") if not username: return None - - user = db.query(User).filter(User.username == username).first() - return user + if isinstance(db, AsyncSession): + result = await db.execute(select(User).where(User.username == username)) + return result.scalar_one_or_none() + return db.query(User).filter(User.username == username).first() -async def get_current_user_optional(request: Request, db: Session = Depends(get_db)) -> Optional[User]: + +async def get_current_user_optional(request: Request, db=Depends(get_async_db)) -> Optional[User]: token = request.cookies.get("access_token") - if not token: # <-- Исправлено: сразу возвращаем None + if not token: return None if token.startswith("Bearer "): token = token[7:] return await get_current_user_from_token(token, db) -async def get_current_user(request: Request, db: Session = Depends(get_db)) -> User: + +async def get_current_user(request: Request, db=Depends(get_async_db)) -> User: token = request.cookies.get("access_token") if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, - ) - + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") if token.startswith("Bearer "): token = token[7:] - user = await get_current_user_from_token(token, db) if user is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") return user + +async def get_current_admin(request: Request, db=Depends(get_async_db)) -> User: + user = await get_current_user(request, db) + if not user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return user + + +# ─── Sync auth deps (for non-converted endpoints) ────────────────────────── + +async def get_current_user_sync(request: Request, db=Depends(get_db)) -> User: + token = request.cookies.get("access_token") + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + if token.startswith("Bearer "): + token = token[7:] + user = db.query(User).filter(User.username == decode_token(token).get("sub")).first() if decode_token(token) else None + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + return user + + +async def get_current_user_optional_sync(request: Request, db=Depends(get_db)) -> Optional[User]: + token = request.cookies.get("access_token") + if not token: + return None + if token.startswith("Bearer "): + token = token[7:] + payload = decode_token(token) + if not payload: + return None + return db.query(User).filter(User.username == payload.get("sub")).first() + + def create_user(db: Session, username: str, password: str) -> User: - hashed_password = get_password_hash(password) - user = User(username=username, hashed_password=hashed_password, balance=1000.0) + hashed = get_password_hash(password) + user = User(username=username, hashed_password=hashed, balance=1000.0) db.add(user) db.commit() db.refresh(user) return user + def authenticate_user(db: Session, username: str, password: str) -> Optional[User]: user = db.query(User).filter(User.username == username).first() - if not user: - return None - if not verify_password(password, user.hashed_password): + if not user or not verify_password(password, user.hashed_password): return None return user + def is_item_craftable(item_id: int) -> bool: - """Проверяет, можно ли использовать предмет в контракте""" from backend import get_item, is_final_in_collection - 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 \ No newline at end of file diff --git a/cache.py b/cache.py new file mode 100644 index 0000000..9645ed0 --- /dev/null +++ b/cache.py @@ -0,0 +1,67 @@ +import os +import json +import asyncio +from typing import Optional, Any, Callable, Awaitable +from datetime import timedelta + +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") +CACHE_TTL = int(os.getenv("CACHE_TTL", "300")) + +_redis_pool = None +_lock = asyncio.Lock() + + +async def get_redis(): + global _redis_pool + if _redis_pool is None: + import aioredis + async with _lock: + if _redis_pool is None: + _redis_pool = await aioredis.from_url(REDIS_URL, decode_responses=True) + return _redis_pool + + +async def cache_get(key: str) -> Optional[Any]: + try: + r = await get_redis() + val = await r.get(key) + if val: + return json.loads(val) + except Exception: + pass + return None + + +async def cache_set(key: str, value: Any, ttl: int = CACHE_TTL): + try: + r = await get_redis() + await r.setex(key, ttl, json.dumps(value, default=str)) + except Exception: + pass + + +async def cache_delete(key: str): + try: + r = await get_redis() + await r.delete(key) + except Exception: + pass + + +async def cache_get_or_set(key: str, factory: Callable[[], Awaitable[Any]], ttl: int = CACHE_TTL) -> Any: + cached = await cache_get(key) + if cached is not None: + return cached + value = await factory() + await cache_set(key, value, ttl) + return value + + +async def cache_invalidate_pattern(pattern: str): + try: + r = await get_redis() + keys = await r.keys(pattern) + if keys: + await r.delete(*keys) + except Exception: + pass diff --git a/database.py b/database.py index 16a8bb3..53de981 100644 --- a/database.py +++ b/database.py @@ -1,27 +1,59 @@ -from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text, text -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, relationship -from datetime import datetime +import os import json from pathlib import Path +from datetime import datetime +from contextlib import asynccontextmanager +from typing import AsyncGenerator, Generator, Optional -DATABASE_URL = "sqlite:///./cs2_simulator.db" -engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +from sqlalchemy import ( + create_engine, Column, Integer, String, Float, Boolean, + DateTime, ForeignKey, Text, text, inspect, Index +) +from sqlalchemy.orm import sessionmaker, relationship, DeclarativeBase +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +# ─── Config ────────────────────────────────────────────────────────────────── +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./cs2_simulator.db") +ASYNC_DATABASE_URL = os.getenv("ASYNC_DATABASE_URL", "sqlite+aiosqlite:///./cs2_simulator.db") +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") +USE_ASYNC = os.getenv("USE_ASYNC", "0") == "1" or "postgresql" in ASYNC_DATABASE_URL + +class Base(DeclarativeBase): + pass + +# ─── Sync engine (SQLite fallback / admin) ────────────────────────────────── +is_sqlite = DATABASE_URL.startswith("sqlite") +sync_connect_args = {"check_same_thread": False} if is_sqlite else {} +engine = create_engine(DATABASE_URL, connect_args=sync_connect_args, pool_pre_ping=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() -# Включаем WAL mode для параллельных чтений и уменьшения SQLITE_BUSY -with engine.connect() as conn: - conn.execute(text("PRAGMA journal_mode=WAL")) - conn.execute(text("PRAGMA synchronous=NORMAL")) - conn.execute(text("PRAGMA cache_size=-64000")) - conn.execute(text("PRAGMA busy_timeout=5000")) - conn.commit() +if is_sqlite: + with engine.connect() as conn: + conn.execute(text("PRAGMA journal_mode=WAL")) + conn.execute(text("PRAGMA synchronous=NORMAL")) + conn.execute(text("PRAGMA cache_size=-64000")) + conn.execute(text("PRAGMA busy_timeout=5000")) + conn.commit() +# ─── Async engine (PostgreSQL production) ─────────────────────────────────── +async_engine = create_async_engine( + ASYNC_DATABASE_URL, + pool_size=20, + max_overflow=10, + pool_pre_ping=True, + echo=False, +) +AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False) + +# ─── Models ───────────────────────────────────────────────────────────────── class User(Base): __tablename__ = "users" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = ( + Index("ix_users_username", "username"), + Index("ix_users_created_at", "created_at"), + ) + + id = Column(Integer, primary_key=True) username = Column(String(50), unique=True, nullable=False) hashed_password = Column(String(200), nullable=False) balance = Column(Float, default=0.0) @@ -29,13 +61,11 @@ class User(Base): is_banned = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.utcnow) last_login = Column(DateTime, nullable=True) - - # Карта (кэш, источник правды — Bank API) card_balance_cached = Column(Float, default=1000.0) - total_deposited = Column(Float, default=0.0) # всего закинуто card→site - total_withdrawn = Column(Float, default=0.0) # всего выведено site→card + total_deposited = Column(Float, default=0.0) + total_withdrawn = Column(Float, default=0.0) last_deposit_date = Column(DateTime, nullable=True) - + inventory_items = relationship("InventoryItem", back_populates="user", cascade="all, delete-orphan") case_openings = relationship("CaseOpening", back_populates="user", cascade="all, delete-orphan") contracts = relationship("Contract", back_populates="user", cascade="all, delete-orphan") @@ -44,39 +74,32 @@ class User(Base): upgrade_rpu = relationship("UpgradeRPU", back_populates="user", uselist=False, cascade="all, delete-orphan") transactions = relationship("TransactionLog", back_populates="user", cascade="all, delete-orphan") + class UpgradeRPU(Base): __tablename__ = "upgrade_rpu" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = (Index("ix_upgrade_rpu_user_id", "user_id", unique=True),) + + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True) - - # Множитель шанса апгрейда (1.0 = стандартный) upgrade_multiplier = Column(Float, default=1.0) - - # Автоматическая подкрутка auto_adjust = Column(Boolean, default=False) - - # Статистика total_attempts = Column(Integer, default=0) total_success = Column(Integer, default=0) - total_spent_value = Column(Float, default=0.0) # Стоимость потерянных предметов - - # Серии + total_spent_value = Column(Float, default=0.0) current_win_streak = Column(Integer, default=0) current_lose_streak = Column(Integer, default=0) best_win_streak = Column(Integer, default=0) worst_lose_streak = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - user = relationship("User", back_populates="upgrade_rpu") class Upgrade(Base): __tablename__ = "upgrades" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = (Index("ix_upgrades_user_id_created", "user_id", "created_at"),) + + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) input_item_id = Column(Integer, nullable=False) input_item_name = Column(String(200)) @@ -85,19 +108,18 @@ class Upgrade(Base): target_item_name = Column(String(200)) target_item_image_url = Column(String(500), default="") success = Column(Boolean, default=False) - probability = Column(Float) # Базовый шанс - rpu_adjusted_probability = Column(Float) # Шанс с учетом РПУ + probability = Column(Float) + rpu_adjusted_probability = Column(Float) created_at = Column(DateTime, default=datetime.utcnow) - user = relationship("User", back_populates="upgrades") + class UserRPU(Base): __tablename__ = "user_rpu" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = (Index("ix_user_rpu_user_id", "user_id", unique=True),) + + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True) - - # Множители шансов для каждой редкости (1.0 = стандартный шанс) consumer_multiplier = Column(Float, default=1.0) industrial_multiplier = Column(Float, default=1.0) mil_spec_multiplier = Column(Float, default=1.0) @@ -105,64 +127,44 @@ class UserRPU(Base): classified_multiplier = Column(Float, default=1.0) covert_multiplier = Column(Float, default=1.0) rare_special_multiplier = Column(Float, default=1.0) - - # Общий множитель удачи luck_multiplier = Column(Float, default=1.0) - - # Автоматическая подкрутка auto_adjust = Column(Boolean, default=False) - - # Статистика для авто-подкрутки total_spent = Column(Float, default=0.0) total_opened = Column(Integer, default=0) total_value_received = Column(Float, default=0.0) last_adjustment = Column(DateTime, nullable=True) - - # Серии current_streak = Column(Integer, default=0) best_streak = Column(Integer, default=0) worst_streak = Column(Integer, default=0) - - # Последние результаты открытий last_results = Column(String(500), default="") - - # ── RPU v2: Session (сессии) ── - luck_budget = Column(Float, default=100.0) # 0-100, остаток удачи на сессию - session_spent = Column(Float, default=0.0) # потрачено в текущей сессии - session_won = Column(Float, default=0.0) # выиграно (ценность) в сессии - session_reset_date = Column(DateTime, nullable=True) # когда сброшена сессия - - # ── RPU v2: Ceiling (потолок) ── - ceiling_multiplier = Column(Float, default=1.0) # временный множитель потолка (0.5-3.0) - ceiling_break_count = Column(Integer, default=0) # сколько раз пробивал потолок - ceiling_break_session = Column(Integer, default=0) # пробитий за сессию - - # ── RPU v2: Comeback (комбек) ── - consecutive_loss_value = Column(Float, default=0.0) # стоимость последовательных проигрышей + luck_budget = Column(Float, default=100.0) + session_spent = Column(Float, default=0.0) + session_won = Column(Float, default=0.0) + session_reset_date = Column(DateTime, nullable=True) + ceiling_multiplier = Column(Float, default=1.0) + ceiling_break_count = Column(Integer, default=0) + ceiling_break_session = Column(Integer, default=0) + consecutive_loss_value = Column(Float, default=0.0) consecutive_loss_count = Column(Integer, default=0) comeback_active = Column(Boolean, default=False) comeback_openings_left = Column(Integer, default=0) - comeback_multiplier = Column(Float, default=1.0) # множитель удачи в режиме комбека - - # ── RPU v2: Hot/Cold анализ ── - hot_score = Column(Float, default=0.0) # >0 = горячий, <0 = холодный + comeback_multiplier = Column(Float, default=1.0) + hot_score = Column(Float, default=0.0) last_activity_date = Column(DateTime, nullable=True) - - # Мета-данные created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - user = relationship("User", back_populates="rpu_settings") class PromoCode(Base): __tablename__ = "promo_codes" - - id = Column(Integer, primary_key=True, index=True) - code = Column(String(50), unique=True, nullable=False, index=True) - reward_type = Column(String(30), nullable=False) # card_to_site, luck_boost, ceiling_boost, free_case, reset_streak + __table_args__ = (Index("ix_promo_codes_code", "code"), Index("ix_promo_codes_active", "is_active")) + + id = Column(Integer, primary_key=True) + code = Column(String(50), unique=True, nullable=False) + reward_type = Column(String(30), nullable=False) reward_amount = Column(Float, default=0.0) - reward_data = Column(String(500), default="") # доп. параметры (JSON) + reward_data = Column(String(500), default="") max_uses = Column(Integer, default=1) used_count = Column(Integer, default=0) start_date = Column(DateTime, default=datetime.utcnow) @@ -174,44 +176,57 @@ class PromoCode(Base): class TransactionLog(Base): __tablename__ = "transaction_log" - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) - tx_type = Column(String(30), nullable=False) # card_to_site, site_to_card, promo_bonus, admin + __table_args__ = ( + Index("ix_txlog_user_id", "user_id"), + Index("ix_txlog_created", "created_at"), + Index("ix_txlog_user_created", "user_id", "created_at"), + ) + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + tx_type = Column(String(30), nullable=False) amount = Column(Float, default=0.0) fee = Column(Float, default=0.0) promo_code = Column(String(50), nullable=True) - item_name = Column(String(200), nullable=True) # при выводе скина + item_name = Column(String(200), nullable=True) inventory_item_id = Column(Integer, nullable=True) details = Column(String(500), default="") created_at = Column(DateTime, default=datetime.utcnow) - user = relationship("User", back_populates="transactions") class InventoryItem(Base): __tablename__ = "inventory_items" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = ( + Index("ix_inventory_user_id", "user_id"), + Index("ix_inventory_user_obtained", "user_id", "obtained_at"), + ) + + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - item_id = Column(Integer, nullable=False) # ID из ALL_ITEMS + item_id = Column(Integer, nullable=False) market_hash_name = Column(String(200), nullable=False) rarity = Column(String(50)) wear = Column(String(50)) float_value = Column(Float, default=0.0) type = Column(String(20), default="Normal") - obtained_from = Column(String(100)) # "case: название" или "contract" + obtained_from = Column(String(100)) obtained_at = Column(DateTime, default=datetime.utcnow) is_equipped = Column(Boolean, default=False) image_url = Column(String(500), default="") price_rub = Column(Float, default=0.0) - user = relationship("User", back_populates="inventory_items") + class CaseOpening(Base): __tablename__ = "case_openings" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = ( + Index("ix_case_openings_user_id", "user_id"), + Index("ix_case_openings_opened_at", "opened_at"), + Index("ix_case_openings_user_opened", "user_id", "opened_at"), + ) + + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) case_name = Column(String(100), nullable=False) item_id = Column(Integer, nullable=False) @@ -219,197 +234,138 @@ class CaseOpening(Base): rarity = Column(String(50)) float_value = Column(Float) opened_at = Column(DateTime, default=datetime.utcnow) - user = relationship("User", back_populates="case_openings") + class Contract(Base): __tablename__ = "contracts" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = ( + Index("ix_contracts_user_id", "user_id"), + Index("ix_contracts_user_created", "user_id", "created_at"), + ) + + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - input_item_ids = Column(Text, nullable=False) # JSON список ID из инвентаря + input_item_ids = Column(Text, nullable=False) output_item_id = Column(Integer, nullable=False) output_item_name = Column(String(200)) output_float = Column(Float) probability = Column(Float) created_at = Column(DateTime, default=datetime.utcnow) - user = relationship("User", back_populates="contracts") class Achievement(Base): __tablename__ = "achievements" - - id = Column(Integer, primary_key=True, index=True) + + id = Column(Integer, primary_key=True) name = Column(String(100), unique=True, nullable=False) title = Column(String(200), nullable=False) description = Column(String(500)) icon = Column(String(50), default="🏆") - category = Column(String(50), default="general") # cases, contracts, upgrade, social, general - requirement_type = Column(String(50), nullable=False) # open_cases, contracts, upgrade_wins, total_spent, valuable_item, etc. - requirement_value = Column(Integer, nullable=False) # how many - reward_amount = Column(Float, default=0.0) # bonus rubles + category = Column(String(50), default="general") + requirement_type = Column(String(50), nullable=False) + requirement_value = Column(Integer, nullable=False) + reward_amount = Column(Float, default=0.0) sort_order = Column(Integer, default=0) - hidden = Column(Boolean, default=False) # секретные достижения - + hidden = Column(Boolean, default=False) user_achievements = relationship("UserAchievement", back_populates="achievement") class UserAchievement(Base): __tablename__ = "user_achievements" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = ( + Index("ix_user_achievements_user", "user_id"), + Index("ix_user_achievements_ach", "achievement_id"), + Index("ix_user_achievements_both", "user_id", "achievement_id", unique=True), + ) + + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False) - progress = Column(Integer, default=0) # текущий прогресс + progress = Column(Integer, default=0) unlocked_at = Column(DateTime, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) - user = relationship("User", backref="user_achievements") achievement = relationship("Achievement", back_populates="user_achievements") class ActivityFeed(Base): __tablename__ = "activity_feed" - - id = Column(Integer, primary_key=True, index=True) + __table_args__ = ( + Index("ix_activity_user_id", "user_id"), + Index("ix_activity_created", "created_at"), + ) + + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) username = Column(String(50), nullable=False) - activity_type = Column(String(50), nullable=False) # case_open, contract, upgrade, achievement, trade, crash + activity_type = Column(String(50), nullable=False) message = Column(String(500), nullable=False) - data_json = Column(Text, default="{}") # дополнительные данные (item_name, price, etc.) + data_json = Column(Text, default="{}") created_at = Column(DateTime, default=datetime.utcnow) - user = relationship("User", backref="activities") -Base.metadata.create_all(bind=engine) -# Миграция: добавляем недостающие индексы для производительности -try: - from sqlalchemy import inspect - inspector = inspect(engine) - indices_to_add = { - "inventory_items": ["user_id"], - "case_openings": ["user_id", "opened_at"], - "upgrades": ["user_id"], - "contracts": ["user_id"], - "activity_feed": ["user_id"], - "user_achievements": ["user_id", "achievement_id"], - } - with engine.connect() as conn: - for tbl, cols in indices_to_add.items(): - existing = [ix["name"] for ix in inspector.get_indexes(tbl)] - for col in cols: - ix_name = f"ix_{tbl}_{col}" - if ix_name not in existing: - conn.execute(text(f"CREATE INDEX IF NOT EXISTS {ix_name} ON {tbl} ({col})")) - conn.commit() - print("[DB] Индексы: OK") -except Exception as e: - print(f"[DB] Миграция индексов: {e}") +# ─── Create tables ────────────────────────────────────────────────────────── +def init_sync_db(): + Base.metadata.create_all(bind=engine) + _run_migrations(engine, is_sync=True) -# Миграция: добавляем колонку hidden если её нет -try: - from sqlalchemy import inspect - inspector = inspect(engine) - cols = [c["name"] for c in inspector.get_columns("achievements")] - if "hidden" not in cols: - from sqlalchemy import text - with engine.connect() as conn: - conn.execute(text("ALTER TABLE achievements ADD COLUMN hidden BOOLEAN DEFAULT 0")) - conn.commit() -except Exception as e: - print(f"[DB] Миграция hidden: {e}") -# Миграция: добавляем image_url и price_rub в inventory_items -try: - from sqlalchemy import text - inspector = inspect(engine) - cols = [c["name"] for c in inspector.get_columns("inventory_items")] - if "image_url" not in cols: - with engine.connect() as conn: - conn.execute(text("ALTER TABLE inventory_items ADD COLUMN image_url VARCHAR(500) DEFAULT ''")) - conn.execute(text("ALTER TABLE inventory_items ADD COLUMN price_rub FLOAT DEFAULT 0.0")) - conn.commit() - # Backfill existing rows from ALL_ITEMS - try: - from backend import ALL_ITEMS - with engine.connect() as conn: - rows = conn.execute(text("SELECT id, item_id FROM inventory_items WHERE image_url = '' OR price_rub = 0.0")).fetchall() - for row_id, item_id in rows: - if 0 <= item_id < len(ALL_ITEMS): - item = ALL_ITEMS[item_id] - img = item.get("image_url", "") - pr = item.get("price_rub", 0.0) - conn.execute( - text("UPDATE inventory_items SET image_url = :img, price_rub = :pr WHERE id = :rid"), - {"img": img, "pr": pr, "rid": row_id} - ) - conn.commit() - print(f"[DB] Backfilled {len(rows)} inventory_items from ALL_ITEMS") - except Exception as e2: - print(f"[DB] Backfill inventory_items: {e2}") -except Exception as e: - print(f"[DB] Миграция inventory_items: {e}") +async def init_async_db(): + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) -# Миграция: новые поля в users (карта) -try: - inspector = inspect(engine) - cols = [c["name"] for c in inspector.get_columns("users")] - if "card_balance_cached" not in cols: - with engine.connect() as conn: - conn.execute(text("ALTER TABLE users ADD COLUMN card_balance_cached FLOAT DEFAULT 1000.0")) - conn.execute(text("ALTER TABLE users ADD COLUMN total_deposited FLOAT DEFAULT 0.0")) - conn.execute(text("ALTER TABLE users ADD COLUMN total_withdrawn FLOAT DEFAULT 0.0")) - conn.execute(text("ALTER TABLE users ADD COLUMN last_deposit_date TIMESTAMP")) - conn.commit() - print("[DB] Миграция users (card): OK") -except Exception as e: - print(f"[DB] Миграция users card: {e}") -# Миграция: новые поля в user_rpu (v2) -try: - inspector = inspect(engine) - cols = [c["name"] for c in inspector.get_columns("user_rpu")] - for col, coltype in [ - ("luck_budget", "FLOAT DEFAULT 100.0"), - ("session_spent", "FLOAT DEFAULT 0.0"), - ("session_won", "FLOAT DEFAULT 0.0"), - ("session_reset_date", "TIMESTAMP"), - ("ceiling_multiplier", "FLOAT DEFAULT 1.0"), - ("ceiling_break_count", "INTEGER DEFAULT 0"), - ("ceiling_break_session", "INTEGER DEFAULT 0"), - ("consecutive_loss_value", "FLOAT DEFAULT 0.0"), - ("consecutive_loss_count", "INTEGER DEFAULT 0"), - ("comeback_active", "BOOLEAN DEFAULT 0"), - ("comeback_openings_left", "INTEGER DEFAULT 0"), - ("comeback_multiplier", "FLOAT DEFAULT 1.0"), - ("hot_score", "FLOAT DEFAULT 0.0"), - ("last_activity_date", "TIMESTAMP"), - ]: - if col not in cols: - with engine.connect() as conn: - conn.execute(text(f"ALTER TABLE user_rpu ADD COLUMN {col} {coltype}")) - conn.commit() - print(f"[DB] Миграция user_rpu: {col}") -except Exception as e: - print(f"[DB] Миграция user_rpu: {e}") +def _run_migrations(engine_or_conn, is_sync: bool): + try: + from sqlalchemy import inspect as sa_inspect + inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn) + existing_tables = inspector.get_table_names() + for tbl_name in Base.metadata.tables: + if tbl_name not in existing_tables: + continue + model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns} + db_cols = {c["name"] for c in inspector.get_columns(tbl_name)} + missing = model_cols - db_cols + if missing: + print(f"[DB] Migrating {tbl_name}: adding {missing}") + except Exception as e: + print(f"[DB] Migration check: {e}") -# Миграция: создаём PromoCode если нет -try: - Base.metadata.tables["promo_codes"].create(bind=engine, checkfirst=True) -except: - pass -# Миграция: создаём TransactionLog если нет -try: - Base.metadata.tables["transaction_log"].create(bind=engine, checkfirst=True) -except: - pass - -def get_db(): +# ─── Session helpers ──────────────────────────────────────────────────────── +def get_db() -> Generator: db = SessionLocal() try: yield db finally: - db.close() \ No newline at end of file + db.close() + + +async def get_async_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as db: + try: + yield db + finally: + await db.close() + + +# ─── Thread-safe sync session wrapper for async endpoints ────────────────── +# Use this as a bridge: runs sync DB ops in executor to avoid blocking event loop. +# The session is closed when the context exits. +import asyncio +from contextlib import asynccontextmanager + + +@asynccontextmanager +async def get_db_executor(): + """Async context manager wrapping sync SessionLocal in executor thread. + Use for endpoints that haven't been fully async-converted yet.""" + loop = asyncio.get_running_loop() + db = await loop.run_in_executor(None, lambda: SessionLocal()) + try: + yield db + finally: + await loop.run_in_executor(None, db.close) diff --git a/frontend.py b/frontend.py index 010af37..4deeca9 100644 --- a/frontend.py +++ b/frontend.py @@ -29,30 +29,29 @@ from pathlib import Path import json from pathlib import Path -from database import get_db, SessionLocal, User, InventoryItem, CaseOpening, Contract, Upgrade, UpgradeRPU, PromoCode +from database import get_db, get_db_executor, get_async_db, AsyncSessionLocal, SessionLocal, User, InventoryItem, CaseOpening, Contract, Upgrade, UpgradeRPU, PromoCode from websocket_manager import manager, crash_game +from sqlalchemy import select, func as sa_func, desc from achievements import ( seed_achievements, check_achievement, check_open_cases_achievements, check_contracts_achievements, - check_upgrade_achievements, check_upgrade_risk_achievements, check_upgrade_result_achievements, - check_rare_item_achievement, check_knife_achievement, - check_spent_achievements, - check_balance_achievements, - check_slot_match_achievements, check_slot_plays_achievements, - check_slot_match_count_achievements, - check_inventory_achievements, - check_dildo_achievements, check_dildo_contract_achievement, - check_crash_achievements, check_crash_cashout_achievements, - check_items_unboxed_achievements, check_total_profit_achievements, - check_achievements_count_achievements, + check_upgrade_achievements, check_upgrade_risk_achievements, + check_upgrade_result_achievements, check_rare_item_achievement, + check_knife_achievement, check_spent_achievements, + check_balance_achievements, check_slot_match_achievements, + check_slot_plays_achievements, check_slot_match_count_achievements, + check_inventory_achievements, check_dildo_achievements, + check_dildo_contract_achievement, check_crash_achievements, + check_crash_cashout_achievements, check_items_unboxed_achievements, + check_total_profit_achievements, check_achievements_count_achievements, check_rare_item_count_achievements, check_knife_count_achievements, check_midnight_case_achievement, check_exact_cases_achievement, - check_collection_achievements, - get_user_achievements + check_collection_achievements, get_user_achievements ) from auth import ( create_user, authenticate_user, create_access_token, get_current_user, get_current_user_optional, get_password_hash, + get_current_user_sync, get_current_user_optional_sync, ACCESS_TOKEN_EXPIRE_MINUTES, is_item_craftable ) @@ -70,9 +69,10 @@ from backend import ( from upgrade import ( get_upgrade_rpu, calculate_upgrade_probability, get_adjusted_upgrade_probability, spin_upgrade_wheel, - update_upgrade_stats + update_upgrade_stats, + get_upgrade_rpu_async, get_adjusted_upgrade_probability_async, + update_upgrade_stats_async ) - from rpu import get_adjusted_weights, get_user_rpu, update_rpu_stats, RARITY_TO_FIELD from auth import get_password_hash @@ -100,6 +100,40 @@ from backend import app as api_app # Монтируем оригинальное API на /api/simulator чтобы избежать конфликтов app.mount("/api/simulator", api_app) +# ─── Pagination helper ───────────────────────────────────────────────────── +DEFAULT_PAGE_SIZE = 50 + +def paginate(query, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE): + offset = (page - 1) * page_size + return query.offset(offset).limit(page_size) + +def paginated_response(items, total: int, page: int, page_size: int = DEFAULT_PAGE_SIZE): + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "pages": max(1, (total + page_size - 1) // page_size), + } + +# ─── Rate Limiting ────────────────────────────────────────────────────────── +from collections import defaultdict +import time +_rate_limit_store: dict = defaultdict(list) + +RATE_LIMIT_WINDOW = 60 # seconds +RATE_LIMIT_MAX_CASES = 30 # max case opens per window +RATE_LIMIT_MAX_GENERAL = 120 # max general requests per window + +async def rate_limit_check(user_id: int, action: str, max_req: int, window: int = RATE_LIMIT_WINDOW): + key = f"{user_id}:{action}" + now = time.time() + _rate_limit_store[key] = [t for t in _rate_limit_store.get(key, []) if now - t < window] + if len(_rate_limit_store[key]) >= max_req: + return False + _rate_limit_store[key].append(now) + return True + # ─── Вспомогательные функции ─────────────────────────────────────────────── # Кэш dildo-предметов (создаётся один раз) @@ -243,9 +277,33 @@ def record_activity_own_session(user_id: int, username: str, activity_type: str, } # Сидируем достижения при старте -db_seed = next(get_db()) -seed_achievements(db_seed) -db_seed.close() +try: + from database import init_sync_db + init_sync_db() + db_seed = next(get_db()) + from achievements import ACHIEVEMENT_DEFS + from database import Achievement + existing = db_seed.query(Achievement).count() + if existing == 0: + for defn in ACHIEVEMENT_DEFS: + db_seed.add(Achievement(**defn)) + db_seed.commit() + print(f"[Seed] Добавлено {len(ACHIEVEMENT_DEFS)} достижений") + else: + existing_names = {a.name for a in db_seed.query(Achievement.name).all()} + added = 0 + for defn in ACHIEVEMENT_DEFS: + if defn["name"] not in existing_names: + db_seed.add(Achievement(**defn)) + added += 1 + if added: + db_seed.commit() + print(f"[Seed] Добавлено {added} новых достижений") + db_seed.close() +except Exception as e: + print(f"[Seed] {e}") + import traceback + traceback.print_exc() async def broadcast_online_count(): """Рассылает всем количество игроков онлайн""" diff --git a/migrate_db.py b/migrate_db.py new file mode 100644 index 0000000..1a03d4e --- /dev/null +++ b/migrate_db.py @@ -0,0 +1,85 @@ +""" +Миграция данных из SQLite в PostgreSQL. +Запуск: python migrate_db.py +Требует настроенного PostgreSQL (см. database.py) +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from database import DATABASE_URL, ASYNC_DATABASE_URL, Base, User, InventoryItem, CaseOpening, Contract +from database import Achievement, UserAchievement, ActivityFeed, UserRPU, UpgradeRPU, Upgrade, PromoCode, TransactionLog + +# Sync SQLite source +SOURCE_URL = DATABASE_URL +# Sync PostgreSQL target (derive from async URL) +TARGET_URL = ASYNC_DATABASE_URL.replace("+asyncpg", "").replace("+aiosqlite", "") + +if "sqlite" in SOURCE_URL: + print(f"Source: {SOURCE_URL}") + print(f"Target: {TARGET_URL}") + print("---") +else: + print("Source already PostgreSQL — nothing to migrate.") + sys.exit(0) + +source_engine = create_engine(SOURCE_URL) +target_engine = create_engine(TARGET_URL, pool_pre_ping=True) + +SourceSession = sessionmaker(bind=source_engine) +TargetSession = sessionmaker(bind=target_engine) + +def migrate_table(model, target_session): + """Copies all rows from source model to target model""" + source_session = SourceSession() + try: + rows = source_session.query(model).all() + if not rows: + return 0 + count = 0 + for row in rows: + # Detach from source session + source_session.expunge(row) + target_session.merge(row) + count += 1 + target_session.commit() + return count + except Exception as e: + target_session.rollback() + print(f" ERROR: {e}") + return 0 + finally: + source_session.close() + +def main(): + print("Creating target tables...") + Base.metadata.create_all(bind=target_engine) + + target_session = TargetSession() + models = [ + (User, "users"), + (UserRPU, "user_rpu"), + (UpgradeRPU, "upgrade_rpu"), + (InventoryItem, "inventory_items"), + (CaseOpening, "case_openings"), + (Contract, "contracts"), + (Achievement, "achievements"), + (UserAchievement, "user_achievements"), + (ActivityFeed, "activity_feed"), + (Upgrade, "upgrades"), + (PromoCode, "promo_codes"), + (TransactionLog, "transaction_log"), + ] + + for model, name in models: + print(f"Migrating {name}... ", end="", flush=True) + count = migrate_table(model, target_session) + print(f"{count} rows") + + target_session.close() + print("\nMigration complete! Set ASYNC_DATABASE_URL to the PostgreSQL URL to use it.") + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index bb70430..fd528ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,9 +6,11 @@ markupsafe>=3.0.0 sqlalchemy>=2.0 python-jose[cryptography] passlib -bcrypt +bcrypt>=4.0.0 python-multipart python-dateutil aiofiles tqdm aiosqlite +asyncpg +redis>=5.0 diff --git a/rpu.py b/rpu.py index b5e09e0..86fe023 100644 --- a/rpu.py +++ b/rpu.py @@ -3,21 +3,16 @@ import random import math from datetime import datetime, date from typing import Dict, Optional, Tuple -from sqlalchemy.orm import Session +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession from database import User, UserRPU, InventoryItem from promo_phase import get_promo_effect_for_user, get_current_phase -# Стандартные веса редкостей BASE_RARITY_WEIGHTS = { - "Consumer Grade": 10000, - "Industrial Grade": 5000, - "Mil-Spec": 2500, - "Mil-Spec Grade": 2500, - "Restricted": 625, - "Classified": 125, - "Covert": 25, - "Rare Special Item": 5, - "Extraordinary": 5 + "Consumer Grade": 10000, "Industrial Grade": 5000, + "Mil-Spec": 2500, "Mil-Spec Grade": 2500, + "Restricted": 625, "Classified": 125, + "Covert": 25, "Rare Special Item": 5, "Extraordinary": 5 } RARITY_TO_FIELD = { @@ -33,421 +28,321 @@ RARITY_TO_FIELD = { } RARITY_RANK = { - "Consumer Grade": 0, - "Industrial Grade": 1, - "Mil-Spec": 2, - "Mil-Spec Grade": 2, - "Restricted": 3, - "Classified": 4, - "Covert": 5, - "Rare Special Item": 6, - "Extraordinary": 6 + "Consumer Grade": 0, "Industrial Grade": 1, + "Mil-Spec": 2, "Mil-Spec Grade": 2, + "Restricted": 3, "Classified": 4, "Covert": 5, + "Rare Special Item": 6, "Extraordinary": 6 } -# Пороги потолка CEILING_BASE_MULTIPLIER = 3.0 CEILING_MIN = 1000.0 -CEILING_SESSION_HISTORY = { - 0: 5.0, # первая сессия - 1: 4.0, # вторая - 2: 3.5, # третья - -1: 3.0 # 3+ сессий -} +CEILING_SESSION_HISTORY = {0: 5.0, 1: 4.0, 2: 3.5, -1: 3.0} -# Пороги для luck_budget BUDGET_MAX = 100.0 -BUDGET_CONSUMPTION_RATE = 0.001 # 0.1% от цены предмета -BUDGET_REGEN_PER_OPEN = 0.5 # +0.5% за каждое открытие -BUDGET_LOW_THRESHOLD = 20.0 # если budget < 20%, удача падает +BUDGET_CONSUMPTION_RATE = 0.001 +BUDGET_REGEN_PER_OPEN = 0.5 +BUDGET_LOW_THRESHOLD = 20.0 -# Комбек -COMEBACK_TRIGGER_COUNT = 5 # 5+ проигрышей подряд → комбек -COMEBACK_TRIGGER_VALUE_RATIO = 0.3 # потеря >30% от total_deposited -COMEBACK_OPENINGS = 5 # комбек на 5 открытий -COMEBACK_MULTIPLIER_MAX = 3.0 # макс множитель удачи в комбеке +COMEBACK_TRIGGER_COUNT = 5 +COMEBACK_TRIGGER_VALUE_RATIO = 0.3 +COMEBACK_OPENINGS = 5 +COMEBACK_MULTIPLIER_MAX = 3.0 -# Hot/Cold -HOT_THRESHOLD_SPENT = 10000 # потратил >10k -HOT_THRESHOLD_WITHDRAWAL = 0.1 # вывел <10% от депозитов -COLD_DAYS_INACTIVE = 7 # 7 дней неактивен = холодный -COLD_SPENT_PER_DAY = 100 # <100₽/день = холодный +HOT_THRESHOLD_SPENT = 10000 +HOT_THRESHOLD_WITHDRAWAL = 0.1 +COLD_DAYS_INACTIVE = 7 +COLD_SPENT_PER_DAY = 100 - -def get_user_rpu(db: Session, user_id: int) -> UserRPU: - rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first() - if not rpu: - rpu = UserRPU(user_id=user_id) - db.add(rpu) - _rpu_commit_or_flush(db) - db.refresh(rpu) - return rpu - - -# ── Session Management ── - -def check_and_reset_session(db: Session, user_id: int): - """ - Проверяет, не пора ли сбросить сессию (каждый календарный день). - """ - rpu = get_user_rpu(db, user_id) - today = date.today() - - if rpu.session_reset_date is None: - rpu.session_reset_date = datetime.utcnow() - rpu.luck_budget = BUDGET_MAX - rpu.session_spent = 0 - rpu.session_won = 0 - rpu.ceiling_break_session = 0 - rpu.comeback_active = False - rpu.comeback_openings_left = 0 - rpu.comeback_multiplier = 1.0 - rpu.consecutive_loss_count = 0 - rpu.consecutive_loss_value = 0.0 - _rpu_commit_or_flush(db) - return - - last_reset = rpu.session_reset_date.date() - if last_reset < today: - rpu.session_reset_date = datetime.utcnow() - rpu.luck_budget = BUDGET_MAX - rpu.session_spent = 0 - rpu.session_won = 0 - rpu.ceiling_break_session = 0 - rpu.comeback_active = False - rpu.comeback_openings_left = 0 - rpu.comeback_multiplier = 1.0 - rpu.consecutive_loss_count = 0 - rpu.consecutive_loss_value = 0.0 - _rpu_commit_or_flush(db) - - -# ── Ceiling (Потолок) ── - -def calculate_ceiling(db: Session, user_id: int) -> float: - """ - Потолок = total_deposited * mult. - mult зависит от количества сессий на сайте. - """ - user = db.query(User).filter(User.id == user_id).first() - if not user: - return CEILING_MIN - - total_dep = max(0.0, user.total_deposited) - - # Считаем, сколько прошло сессий (дней с первой транзакции) - rpu = get_user_rpu(db, user_id) - session_count = 0 - if rpu.session_reset_date and user.created_at: - days_since = (datetime.utcnow() - user.created_at).days - session_count = max(0, days_since) - - if session_count == 0: - mult = CEILING_SESSION_HISTORY[0] - elif session_count == 1: - mult = CEILING_SESSION_HISTORY[1] - elif session_count == 2: - mult = CEILING_SESSION_HISTORY[2] - else: - mult = CEILING_SESSION_HISTORY[-1] - - # Учитываем ceiling_multiplier (временные модификаторы) - mult *= rpu.ceiling_multiplier - - ceiling = total_dep * mult - return max(CEILING_MIN, ceiling) - - -def get_total_inventory_value(db: Session, user_id: int) -> float: - """Суммарная стоимость инвентаря пользователя""" - items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all() - return sum(item.price_rub or 0 for item in items) - - -def get_user_total_value(db: Session, user_id: int) -> float: - """Баланс + инвентарь""" - user = db.query(User).filter(User.id == user_id).first() - balance = user.balance if user else 0 - inv = get_total_inventory_value(db, user_id) - return balance + inv - - -def get_ceiling_luck_factor(db: Session, user_id: int) -> float: - """ - Возвращает множитель удачи на основе близости к потолку (0.3–1.0). - Чем ближе к потолку — тем меньше множитель. - Не блокирует открытие, а лишь делает удачу чуть хуже. - """ - user = db.query(User).filter(User.id == user_id).first() - if not user: - return 1.0 - - ceiling = calculate_ceiling(db, user_id) - inv_value = get_total_inventory_value(db, user_id) - withdrawn = user.total_withdrawn or 0 - extracted_value = inv_value + withdrawn - - if ceiling <= 0: - return 1.0 - - ratio = extracted_value / ceiling - - if ratio < 0.8: - return 1.0 - - if ratio >= 1.0: - return 0.3 - - return 1.0 - (ratio - 0.8) / 0.2 * 0.7 - - -def check_ceiling_breach(db: Session, user_id: int, item_price: float, - case_price: float) -> bool: - """ - Проверяет, пробивает ли предмет потолок. - Если предмет дороже 50% от потолка — пробитие. - """ - user = db.query(User).filter(User.id == user_id).first() - if not user: - return False - - ceiling = calculate_ceiling(db, user_id) - total_value = user.balance + get_total_inventory_value(db, user_id) - - # Если инвентарь уже под потолком — не пробиваем - if total_value < ceiling: - return False - - # Сам предмет должен быть дорогим - if item_price < ceiling * 0.5: - return False - - return True - - -def apply_ceiling_breach(db: Session, user_id: int, item_price: float): - """ - Пробитие потолка: расширяем потолок на 50% на остаток сессии. - """ - rpu = get_user_rpu(db, user_id) - rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5) - rpu.ceiling_break_count += 1 - rpu.ceiling_break_session += 1 - _rpu_commit_or_flush(db) - - -def apply_ceiling_withdrawal_penalty(db: Session, user_id: int): - """ - При большом выводе — скручиваем потолок на следующих N сессий. - """ - rpu = get_user_rpu(db, user_id) - rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7) - _rpu_commit_or_flush(db) - - -# ── Luck Budget ── - -# Флаг: если True — sub-функции делают flush вместо commit. -# Устанавливается в True на время горячего пути (case_open, upgrade). _BATCH_MODE = False - -# Кэш adjusted_weights на время batch-операции -# Ключ: user_id, значение: результат get_adjusted_weights _BATCH_WEIGHTS_CACHE = {} + def _rpu_commit_or_flush(db): if _BATCH_MODE: db.flush() else: db.commit() + +async def _rpu_commit_or_flush_async(db: AsyncSession): + if _BATCH_MODE: + await db.flush() + else: + await db.commit() + + def _batch_cache_get(user_id: int): - """Возвращает кэшированные adjusted_weights для batch или None.""" if _BATCH_MODE and user_id in _BATCH_WEIGHTS_CACHE: return _BATCH_WEIGHTS_CACHE[user_id] return None + def _batch_cache_set(user_id: int, weights: dict): - """Сохраняет adjusted_weights в кэш batch.""" if _BATCH_MODE: _BATCH_WEIGHTS_CACHE[user_id] = weights + def _batch_cache_clear(user_id: int = None): - """Очищает кэш batch (по user_id или полностью).""" if user_id is not None: _BATCH_WEIGHTS_CACHE.pop(user_id, None) else: _BATCH_WEIGHTS_CACHE.clear() -def consume_luck_budget(db: Session, user_id: int, item_price: float): - rpu = get_user_rpu(db, user_id) - consumption = item_price * BUDGET_CONSUMPTION_RATE - rpu.luck_budget = max(0.0, rpu.luck_budget - consumption) - _rpu_commit_or_flush(db) + +async def get_user_rpu(db: AsyncSession, user_id: int) -> UserRPU: + result = await db.execute(select(UserRPU).where(UserRPU.user_id == user_id)) + rpu = result.scalar_one_or_none() + if not rpu: + rpu = UserRPU(user_id=user_id) + db.add(rpu) + await _rpu_commit_or_flush_async(db) + await db.refresh(rpu) + return rpu -def regen_luck_budget(db: Session, user_id: int): - rpu = get_user_rpu(db, user_id) +async def check_and_reset_session(db: AsyncSession, user_id: int): + rpu = await get_user_rpu(db, user_id) + today = date.today() + if rpu.session_reset_date is None: + rpu.session_reset_date = datetime.utcnow() + rpu.luck_budget = BUDGET_MAX + rpu.session_spent = 0; rpu.session_won = 0 + rpu.ceiling_break_session = 0 + rpu.comeback_active = False; rpu.comeback_openings_left = 0 + rpu.comeback_multiplier = 1.0; rpu.consecutive_loss_count = 0 + rpu.consecutive_loss_value = 0.0 + await _rpu_commit_or_flush_async(db) + return + last_reset = rpu.session_reset_date.date() + if last_reset < today: + rpu.session_reset_date = datetime.utcnow() + rpu.luck_budget = BUDGET_MAX + rpu.session_spent = 0; rpu.session_won = 0 + rpu.ceiling_break_session = 0 + rpu.comeback_active = False; rpu.comeback_openings_left = 0 + rpu.comeback_multiplier = 1.0; rpu.consecutive_loss_count = 0 + rpu.consecutive_loss_value = 0.0 + await _rpu_commit_or_flush_async(db) + + +async def calculate_ceiling(db: AsyncSession, user_id: int) -> float: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + return CEILING_MIN + total_dep = max(0.0, user.total_deposited) + rpu = await get_user_rpu(db, user_id) + session_count = 0 + if rpu.session_reset_date and user.created_at: + days_since = (datetime.utcnow() - user.created_at).days + session_count = max(0, days_since) + mult = CEILING_SESSION_HISTORY.get(session_count, CEILING_SESSION_HISTORY[-1]) + mult *= rpu.ceiling_multiplier + ceiling = total_dep * mult + return max(CEILING_MIN, ceiling) + + +async def get_total_inventory_value(db: AsyncSession, user_id: int) -> float: + result = await db.execute( + select(func.coalesce(func.sum(InventoryItem.price_rub), 0)) + .where(InventoryItem.user_id == user_id) + ) + return float(result.scalar() or 0) + + +async def get_user_total_value(db: AsyncSession, user_id: int) -> float: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + balance = user.balance if user else 0 + inv = await get_total_inventory_value(db, user_id) + return balance + inv + + +async def get_ceiling_luck_factor(db: AsyncSession, user_id: int) -> float: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + return 1.0 + ceiling = await calculate_ceiling(db, user_id) + inv_value = await get_total_inventory_value(db, user_id) + withdrawn = user.total_withdrawn or 0 + extracted_value = inv_value + withdrawn + if ceiling <= 0: + return 1.0 + ratio = extracted_value / ceiling + if ratio < 0.8: + return 1.0 + if ratio >= 1.0: + return 0.3 + return 1.0 - (ratio - 0.8) / 0.2 * 0.7 + + +async def check_ceiling_breach(db: AsyncSession, user_id: int, item_price: float, case_price: float) -> bool: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + return False + ceiling = await calculate_ceiling(db, user_id) + total_value = user.balance + await get_total_inventory_value(db, user_id) + if total_value < ceiling: + return False + if item_price < ceiling * 0.5: + return False + return True + + +async def apply_ceiling_breach(db: AsyncSession, user_id: int, item_price: float): + rpu = await get_user_rpu(db, user_id) + rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5) + rpu.ceiling_break_count += 1 + rpu.ceiling_break_session += 1 + await _rpu_commit_or_flush_async(db) + + +async def apply_ceiling_withdrawal_penalty(db: AsyncSession, user_id: int): + rpu = await get_user_rpu(db, user_id) + rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7) + await _rpu_commit_or_flush_async(db) + + +async def consume_luck_budget(db: AsyncSession, user_id: int, item_price: float): + rpu = await get_user_rpu(db, user_id) + rpu.luck_budget = max(0.0, rpu.luck_budget - item_price * BUDGET_CONSUMPTION_RATE) + await _rpu_commit_or_flush_async(db) + + +async def regen_luck_budget(db: AsyncSession, user_id: int): + rpu = await get_user_rpu(db, user_id) rpu.luck_budget = min(BUDGET_MAX, rpu.luck_budget + BUDGET_REGEN_PER_OPEN) - _rpu_commit_or_flush(db) + await _rpu_commit_or_flush_async(db) def get_budget_factor(rpu: UserRPU) -> float: - """ - Возвращает множитель на основе luck_budget. - Если budget низкий (<20%) — удача падает. - """ if rpu.luck_budget >= BUDGET_LOW_THRESHOLD: return 1.0 return max(0.3, rpu.luck_budget / BUDGET_LOW_THRESHOLD) -# ── Comeback System ── - -def update_comeback_tracking(db: Session, user_id: int, spent: float, - received_value: float): - """ - Отслеживает проигрыши для триггера комбека. - """ - rpu = get_user_rpu(db, user_id) - user = db.query(User).filter(User.id == user_id).first() +async def update_comeback_tracking(db: AsyncSession, user_id: int, spent: float, received_value: float): + rpu = await get_user_rpu(db, user_id) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() if not user: return - - # Если проиграл (получил меньше чем потратил) if received_value < spent * 0.8: rpu.consecutive_loss_count += 1 rpu.consecutive_loss_value += (spent - received_value) else: rpu.consecutive_loss_count = 0 rpu.consecutive_loss_value = 0.0 - - # Проверяем триггер комбека total_dep = max(1.0, user.total_deposited) loss_ratio = rpu.consecutive_loss_value / total_dep - if (rpu.consecutive_loss_count >= COMEBACK_TRIGGER_COUNT or loss_ratio >= COMEBACK_TRIGGER_VALUE_RATIO): if not rpu.comeback_active: rpu.comeback_active = True rpu.comeback_openings_left = COMEBACK_OPENINGS rpu.comeback_multiplier = COMEBACK_MULTIPLIER_MAX - - _rpu_commit_or_flush(db) + await _rpu_commit_or_flush_async(db) -def apply_comeback(db: Session, user_id: int) -> float: - """ - Применяет комбек-множитель если активен. - Возвращает множитель для следующего открытия. - """ - rpu = get_user_rpu(db, user_id) +async def apply_comeback(db: AsyncSession, user_id: int) -> float: + rpu = await get_user_rpu(db, user_id) if not rpu.comeback_active or rpu.comeback_openings_left <= 0: return 1.0 - mult = rpu.comeback_multiplier rpu.comeback_openings_left -= 1 - if rpu.comeback_openings_left <= 0: rpu.comeback_active = False rpu.comeback_multiplier = 1.0 rpu.consecutive_loss_count = 0 rpu.consecutive_loss_value = 0.0 - - _rpu_commit_or_flush(db) + await _rpu_commit_or_flush_async(db) return mult -# ── Hot/Cold Analysis ── - -def analyze_hot_cold(db: Session, user_id: int) -> Tuple[bool, bool, float]: - """ - Анализирует пользователя: горячий / холодный. - Возвращает (is_hot, is_cold, withdrawal_ratio). - """ - user = db.query(User).filter(User.id == user_id).first() +async def analyze_hot_cold(db: AsyncSession, user_id: int) -> Tuple[bool, bool, float]: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() if not user: return False, True, 0.0 - total_dep = max(0.0, user.total_deposited) total_wd = max(0.0, user.total_withdrawn) wd_ratio = total_wd / max(1.0, total_dep) - - # Горячий: много потратил, почти не выводил is_hot = (total_dep >= HOT_THRESHOLD_SPENT and wd_ratio < HOT_THRESHOLD_WITHDRAWAL) - - # Холодный: давно не был или мало активность - rpu = get_user_rpu(db, user_id) + rpu = await get_user_rpu(db, user_id) is_cold = False if rpu.last_activity_date: days_since = (datetime.utcnow() - rpu.last_activity_date).days if days_since >= COLD_DAYS_INACTIVE: is_cold = True else: - is_cold = True # Новичок без активности - - # Считаем hot_score + is_cold = True days_active = max(1, (datetime.utcnow() - user.created_at).days) spent_per_day = total_dep / days_active if spent_per_day < COLD_SPENT_PER_DAY and total_dep > 0: is_cold = True - hot_score = total_dep - (total_wd * 2) rpu.hot_score = hot_score rpu.last_activity_date = datetime.utcnow() - _rpu_commit_or_flush(db) - + await _rpu_commit_or_flush_async(db) return is_hot, is_cold, wd_ratio -# ── Withdrawal Tax (Открутка при выводе) ── - -def check_withdrawal_penalty(db: Session, user_id: int): - """ - Если пользователь вывел >50% от депозитов — скручиваем удачу и потолок. - """ - user = db.query(User).filter(User.id == user_id).first() +async def check_withdrawal_penalty(db: AsyncSession, user_id: int): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() if not user: return - total_dep = max(1.0, user.total_deposited) total_wd = max(0.0, user.total_withdrawn) wd_ratio = total_wd / total_dep - if wd_ratio > 0.5: - rpu = get_user_rpu(db, user_id) + rpu = await get_user_rpu(db, user_id) rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - wd_ratio * 0.3) rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7) - _rpu_commit_or_flush(db) + await _rpu_commit_or_flush_async(db) -# ── V2: Enhanced get_adjusted_weights ── +def calculate_rpu_level(rpu: UserRPU) -> float: + multipliers = [ + rpu.consumer_multiplier, rpu.industrial_multiplier, + rpu.mil_spec_multiplier, rpu.restricted_multiplier, + rpu.classified_multiplier, rpu.covert_multiplier, + rpu.rare_special_multiplier + ] + avg = sum(multipliers) / len(multipliers) + if avg >= 1.0: + level = 50 - ((avg - 1.0) / 2.0) * 100 + else: + level = 50 + ((1.0 - avg) / 0.9) * 50 + return max(0, min(100, level)) -def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]: - """ - Получает веса редкостей с учетом РПУ v2: - - сессии, потолок, luck_budget, комбек, промо-фаза, hot/cold - - В batch-режиме результат кэшируется — повторные вызовы с тем же user_id - возвращают закешированное значение без DB-запросов. - """ + +def get_rpu_description(level: float) -> str: + if level < 20: return "🍀 Очень везучий" + elif level < 35: return "😊 Везучий" + elif level < 45: return "🙂 Немного везучий" + elif level < 55: return "😐 Обычный" + elif level < 70: return "🙁 Немного невезучий" + elif level < 85: return "😟 Невезучий" + else: return "💀 Проклятый" + + +async def get_adjusted_weights(db: AsyncSession, user_id: int) -> Dict[str, float]: cached = _batch_cache_get(user_id) if cached is not None: return cached - check_and_reset_session(db, user_id) - rpu = get_user_rpu(db, user_id) + await check_and_reset_session(db, user_id) + rpu = await get_user_rpu(db, user_id) + level = calculate_rpu_level(rpu) + is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id) - # Промо-фаза promo = get_promo_effect_for_user(user_id, rpu.hot_score, wd_ratio, is_cold) - - # Комбек - comeback_mult = apply_comeback(db, user_id) - - # Budget factor + comeback_mult = await apply_comeback(db, user_id) budget_factor = get_budget_factor(rpu) - - # Ceiling factor — не блокирует, а лишь приглушает удачу - ceiling_factor = get_ceiling_luck_factor(db, user_id) + ceiling_factor = await get_ceiling_luck_factor(db, user_id) adjusted = {} for rarity, base_weight in BASE_RARITY_WEIGHTS.items(): @@ -455,29 +350,18 @@ def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]: base_multiplier = getattr(rpu, field, 1.0) if field else 1.0 rarity_index = RARITY_RANK.get(rarity, 0) - if rpu_level < 50: - luck_factor = 1.0 + ((50 - rpu_level) / 50) * rarity_index * 0.3 + if level < 50: + luck_factor = 1.0 + ((50 - level) / 50) * rarity_index * 0.3 else: - luck_factor = 1.0 - ((rpu_level - 50) / 50) * rarity_index * 0.2 - + luck_factor = 1.0 - ((level - 50) / 50) * rarity_index * 0.2 luck_factor = max(0.1, min(3.0, luck_factor)) - # Применяем все множители final = base_multiplier * rpu.luck_multiplier * luck_factor - - # Промо-фаза final *= (1.0 + promo["luck_modifier"]) - - # Budget final *= budget_factor - - # Комбек final *= comeback_mult - - # Потолок — не блокирует, а лишь приглушает удачу final *= ceiling_factor - # Редкие предметы получают доп модификатор от промо if rarity_index >= 3: final *= (1.0 + promo.get("rare_chance_modifier", 0)) @@ -488,86 +372,40 @@ def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]: return adjusted -def calculate_rpu_level(rpu: UserRPU) -> float: - multipliers = [ - rpu.consumer_multiplier, - rpu.industrial_multiplier, - rpu.mil_spec_multiplier, - rpu.restricted_multiplier, - rpu.classified_multiplier, - rpu.covert_multiplier, - rpu.rare_special_multiplier - ] - - avg = sum(multipliers) / len(multipliers) - - if avg >= 1.0: - level = 50 - ((avg - 1.0) / 2.0) * 100 - else: - level = 50 + ((1.0 - avg) / 0.9) * 50 - - return max(0, min(100, level)) - - -def get_rpu_description(level: float) -> str: - if level < 20: - return "🍀 Очень везучий" - elif level < 35: - return "😊 Везучий" - elif level < 45: - return "🙂 Немного везучий" - elif level < 55: - return "😐 Обычный" - elif level < 70: - return "🙁 Немного невезучий" - elif level < 85: - return "😟 Невезучий" - else: - return "💀 Проклятый" - - -def auto_adjust_rpu(db: Session, user_id: int): - rpu = get_user_rpu(db, user_id) +async def auto_adjust_rpu(db: AsyncSession, user_id: int): + rpu = await get_user_rpu(db, user_id) if not rpu.auto_adjust: return - - user = db.query(User).filter(User.id == user_id).first() + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() if not user: return if rpu.total_opened > 0 and rpu.total_spent > 0: roi = rpu.total_value_received / rpu.total_spent - if roi < 0.4: boost = (0.4 - roi) * 2 rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost) for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: - v = getattr(rpu, r) - setattr(rpu, r, min(2.0, v + boost * 0.3)) - + setattr(rpu, r, min(2.0, getattr(rpu, r) + boost * 0.3)) elif roi > 2.0: penalty = (roi - 2.0) * 0.5 rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty) for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: - v = getattr(rpu, r) - setattr(rpu, r, max(0.3, v - penalty * 0.5)) + setattr(rpu, r, max(0.3, getattr(rpu, r) - penalty * 0.5)) streak = rpu.current_streak - if streak <= -5: boost = min(0.3, abs(streak) * 0.03) rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost) rpu.classified_multiplier = min(2.0, rpu.classified_multiplier + boost * 0.5) rpu.covert_multiplier = min(1.8, rpu.covert_multiplier + boost * 0.4) rpu.rare_special_multiplier = min(1.5, rpu.rare_special_multiplier + boost * 0.3) - if streak >= 5: penalty = min(0.2, streak * 0.02) rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty) for r in ['classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: - v = getattr(rpu, r) - setattr(rpu, r, max(0.2, v - penalty * 0.5)) - + setattr(rpu, r, max(0.2, getattr(rpu, r) - penalty * 0.5)) if rpu.total_opened > 50: rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05) rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03) @@ -575,19 +413,17 @@ def auto_adjust_rpu(db: Session, user_id: int): for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier', 'restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: - value = getattr(rpu, field) - setattr(rpu, field, max(0.1, min(3.0, value))) - + setattr(rpu, field, max(0.1, min(3.0, getattr(rpu, field)))) rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier)) rpu.last_adjustment = datetime.utcnow() - _rpu_commit_or_flush(db) + await _rpu_commit_or_flush_async(db) -def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1, - received_value: float = 0.0, rarity: str = None, - is_rare: bool = False): - rpu = get_user_rpu(db, user_id) - user = db.query(User).filter(User.id == user_id).first() +async def update_rpu_stats(db: AsyncSession, user_id: int, spent: float, opened: int = 1, + received_value: float = 0.0, rarity: str = None, is_rare: bool = False): + rpu = await get_user_rpu(db, user_id) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() rpu.total_spent += spent rpu.total_opened += opened @@ -605,45 +441,35 @@ def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1, if rpu.current_streak < rpu.worst_streak: rpu.worst_streak = rpu.current_streak - # ── RPU v2: Luck Budget ── - consume_luck_budget(db, user_id, received_value) - regen_luck_budget(db, user_id) + await consume_luck_budget(db, user_id, received_value) + await regen_luck_budget(db, user_id) + await update_comeback_tracking(db, user_id, spent, received_value) - # ── RPU v2: Comeback tracking ── - update_comeback_tracking(db, user_id, spent, received_value) - - # ── RPU v2: Ceiling breach check ── if user and is_rare and received_value > 0: - if check_ceiling_breach(db, user_id, received_value, spent): - apply_ceiling_breach(db, user_id, received_value) + if await check_ceiling_breach(db, user_id, received_value, spent): + await apply_ceiling_breach(db, user_id, received_value) - # ── RPU v2: Hot/Cold ── - analyze_hot_cold(db, user_id) - - _rpu_commit_or_flush(db) + await analyze_hot_cold(db, user_id) + await _rpu_commit_or_flush_async(db) if rpu.auto_adjust: - auto_adjust_rpu(db, user_id) + await auto_adjust_rpu(db, user_id) -def get_rpu_v2_info(db: Session, user_id: int) -> dict: - """ - Возвращает полную информацию о RPU v2 для профиля/админки. - """ - check_and_reset_session(db, user_id) - rpu = get_user_rpu(db, user_id) - user = db.query(User).filter(User.id == user_id).first() +async def get_rpu_v2_info(db: AsyncSession, user_id: int) -> dict: + await check_and_reset_session(db, user_id) + rpu = await get_user_rpu(db, user_id) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() if not user: return {} - ceiling = calculate_ceiling(db, user_id) - inv_value = get_total_inventory_value(db, user_id) + ceiling = await calculate_ceiling(db, user_id) + inv_value = await get_total_inventory_value(db, user_id) total_value = user.balance + inv_value - is_hot, is_cold, wd_ratio = analyze_hot_cold(db, user_id) + is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id) - session_count = 0 - if user.created_at: - session_count = max(0, (datetime.utcnow() - user.created_at).days) + session_count = max(0, (datetime.utcnow() - user.created_at).days) if user.created_at else 0 return { "rpu_level": round(calculate_rpu_level(rpu)), diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..a5e415a --- /dev/null +++ b/start.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# CS2 Simulator — Production Start Script +# Starts: PostgreSQL-backed web app + WebSocket server + +set -e + +# Ensure PostgreSQL is running +pg_isready -q || service postgresql start + +# Ensure Redis is running +redis-cli ping > /dev/null 2>&1 || redis-server --daemonize yes + +# Set production env +export USE_ASYNC=1 +export ASYNC_DATABASE_URL="${ASYNC_DATABASE_URL:-postgresql+asyncpg://dodep:dodep123@localhost/dodep}" +export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" + +echo "=== Starting CS2 Simulator ===" +echo "DB: $ASYNC_DATABASE_URL" +echo "WS Server: port 13670" +echo "Web App: port 13669" + +# Kill old processes +pkill -f "uvicorn frontend:app" 2>/dev/null || true +pkill -f "uvicorn ws_server:app" 2>/dev/null || true +sleep 1 + +# Start WebSocket server +nohup venv/bin/python -m uvicorn ws_server:app --host 0.0.0.0 --port 13670 --workers 1 > ws_server.log 2>&1 & +echo "[WS] Started on port 13670" + +# Start main web app with multiple workers +nohup venv/bin/python -m uvicorn frontend:app --host 0.0.0.0 --port 13669 --workers 4 --loop uvloop > frontend.log 2>&1 & +echo "[WEB] Started on port 13669 (4 workers)" + +echo "=== Ready ===" +echo "Web: http://localhost:13669" +echo "WS: ws://localhost:13670/ws/{user_id}" diff --git a/static/css/style.min.css b/static/css/style.min.css new file mode 100644 index 0000000..054448c --- /dev/null +++ b/static/css/style.min.css @@ -0,0 +1 @@ +@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Russo+One&display=swap');*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}:root{--primary:#f59e0b;--primary-hover:#d97706;--primary-glow:rgba(245,158,11,0.25);--success:#10b981;--danger:#ef4444;--warning:#f59e0b;--bg-deep:#08090d;--bg-dark:#0b0c10;--bg-card:#13141a;--bg-surface:#1a1b23;--bg-elevated:#21222c;--text:#e8e6e3;--text-dim:#8a8a9a;--text-muted:#5a5a6a;--border:rgba(255,255,255,0.06);--border-light:rgba(255,255,255,0.1);--rarity-consumer:#b0b0b0;--rarity-industrial:#5e98d9;--rarity-mil-spec:#4b69ff;--rarity-restricted:#8847ff;--rarity-classified:#d32ce6;--rarity-covert:#eb4b4b;--rarity-rare-special:#ffd700;--rarity-extraordinary:#ffd700;--rarity-contraband:#fa0;--primary-color:var(--primary);--primary-hover:#d97706;--success-color:var(--success);--danger-color:var(--danger);--warning-color:var(--warning);--bg-dark:var(--bg-deep);--bg-card:#13141a;--bg-light:var(--bg-surface);--text-primary:var(--text);--text-secondary:var(--text-dim);--border-color:var(--border);--radius-sm:6px;--radius-md:10px;--radius-lg:14px;--radius-xl:20px;--transition:.2s cubic-bezier(0.4,0,0.2,1)}body{font-family:'Rajdhani',sans-serif;background:var(--bg-deep);color:var(--text);line-height:1.6;min-height:100vh;overflow-x:hidden}h1,h2,h3,h4,h5,h6,.logo,.nav-link,.btn{font-family:'Russo One',sans-serif;letter-spacing:.03em}::selection{background:var(--primary);color:#000}::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}.container{max-width:1200px;margin:0 auto;padding:0 20px;width:100%}.navbar{background:rgba(11,12,16,0.85);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:.7rem 0;position:sticky;top:0;z-index:100}.navbar .container{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem}.logo{font-size:1.2rem;font-weight:700;color:var(--text);text-decoration:none;text-transform:uppercase;letter-spacing:.08em}.logo:hover{color:var(--primary-color)}.nav-links{display:flex;align-items:center;gap:.25rem;flex-wrap:wrap}.nav-link{color:var(--text-secondary);text-decoration:none;padding:.4rem .65rem;border-radius:6px;transition:all .2s;font-size:.8rem;text-transform:uppercase;letter-spacing:.06em;font-weight:500}.nav-link:hover{color:var(--text-primary);background:rgba(255,255,255,0.04)}.nav-link.active{color:#000;background:var(--primary);font-weight:600;box-shadow:0 0 12px var(--primary-glow)}.user-balance{padding:.4rem .65rem;background:rgba(0,0,0,0.3);border:1px solid rgba(245,158,11,0.2);border-radius:6px;font-weight:600;color:var(--primary-color);font-size:.8rem;font-family:'Rajdhani',sans-serif}.btn{padding:.5rem 1.1rem;border:none;border-radius:var(--radius-sm);font-size:.78rem;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:.35rem;transition:all var(--transition);white-space:nowrap;text-transform:uppercase;letter-spacing:.06em;line-height:1.2}.btn-primary{background:linear-gradient(135deg,var(--primary),#d97706);color:#000}.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 20px var(--primary-glow)}.btn-primary:disabled{opacity:.3;cursor:not-allowed;transform:none;box-shadow:none}.btn-outline{background:transparent;color:var(--text-dim);border:1px solid var(--border)}.btn-outline:hover{color:var(--text-primary);border-color:rgba(255,255,255,0.12);background:rgba(255,255,255,0.03)}.btn-large{padding:.65rem 2rem;font-size:.95rem}.btn-block{width:100%}.btn-fast{width:40px;height:40px;padding:0;display:flex;align-items:center;justify-content:center;font-size:1.1rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.25);color:#f59e0b;border-radius:8px;cursor:pointer;transition:background .2s,border-color .2s,transform .15s;flex-shrink:0}.btn-fast:hover:not(:disabled){background:rgba(245,158,11,0.2);border-color:rgba(245,158,11,0.4);transform:scale(1.05)}.btn-fast:disabled{opacity:.3;cursor:not-allowed}.btn-sm{padding:.25rem .65rem;font-size:.7rem}.btn-success{background:var(--success-color);color:#0a0b0e;border:none}.btn-success:hover{background:#059669;box-shadow:0 4px 15px rgba(16,185,129,0.25)}.hero{padding:4rem 0;text-align:center;position:relative}.hero::before{content:'';position:absolute;top:-50%;left:50%;transform:translateX(-50%);width:600px;height:600px;background:radial-gradient(circle,rgba(245,158,11,0.04) 0,transparent 70%);pointer-events:none}.hero h1{font-size:3rem;margin-bottom:1rem;background:linear-gradient(135deg,var(--primary-color),#d97706);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;position:relative}.hero-subtitle{font-size:1.25rem;color:var(--text-secondary);margin-bottom:3rem;position:relative}.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:1.5rem;margin:3rem 0}.feature-card{background:var(--bg-card);padding:1.75rem 1.5rem;border-radius:10px;text-align:center;border:1px solid var(--border-color);transition:all .25s}.feature-card:hover{border-color:rgba(245,158,11,0.15);transform:translateY(-2px);box-shadow:0 8px 30px rgba(0,0,0,0.3)}.feature-icon{font-size:2.5rem;margin-bottom:.75rem}.feature-card h3{margin-bottom:.5rem;font-size:.95rem;text-transform:uppercase;letter-spacing:.06em}.feature-card p{color:var(--text-secondary);font-size:.85rem}.hero-actions{display:flex;gap:.75rem;justify-content:center;margin-top:2rem}.profile-card,.auth-card,.page-card{background:rgba(20,21,26,0.8);border:1px solid var(--border-color);border-radius:10px;padding:1.5rem;backdrop-filter:blur(8px)}.form-input{width:100%;padding:.55rem .7rem;background:rgba(0,0,0,0.3);border:1px solid var(--border-color);border-radius:6px;color:var(--text-primary);font-size:.9rem;font-family:'Rajdhani',sans-serif;outline:none;transition:border-color .2s;box-sizing:border-box}.form-input:focus{border-color:rgba(245,158,11,0.3)}.form-label{font-size:.8rem;color:var(--text-secondary);margin-bottom:.3rem;display:block;text-transform:uppercase;letter-spacing:.05em}.stat-value{font-family:'Russo One',sans-serif;color:var(--primary-color)}.auth-container{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 73px);padding:2rem}.auth-card{background:rgba(20,21,26,0.8);border:1px solid var(--border-color);padding:2rem;border-radius:10px;width:100%;max-width:400px;backdrop-filter:blur(8px)}.auth-card h2{text-align:center;margin-bottom:1.5rem;font-size:1.25rem;text-transform:uppercase;letter-spacing:.06em}.auth-form{display:flex;flex-direction:column;gap:1rem}.form-group{display:flex;flex-direction:column;gap:.3rem}.form-group label{font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary)}.form-group input{padding:.55rem .7rem;background:rgba(0,0,0,0.3);border:1px solid var(--border-color);border-radius:6px;color:var(--text-primary);font-size:.9rem;font-family:'Rajdhani',sans-serif;outline:none;transition:border-color .2s}.form-group input:focus{border-color:rgba(245,158,11,0.3)}.form-group small{color:var(--text-secondary);font-size:.75rem}.error-message{background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);color:var(--danger-color);padding:.6rem .75rem;border-radius:6px;text-align:center;font-size:.85rem}.auth-footer{text-align:center;margin-top:1.25rem;color:var(--text-secondary);font-size:.85rem}.auth-footer a{color:var(--primary-color);text-decoration:none;font-weight:600}.auth-footer a:hover{text-decoration:underline}.profile-container{padding:2rem 0}.profile-header{margin-bottom:2rem}.profile-header h1{font-size:2rem;margin-bottom:.5rem;text-transform:uppercase;letter-spacing:.03em}.profile-subtitle{color:var(--text-secondary);font-size:.9rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:rgba(20,21,26,0.8);border:1px solid var(--border-color);padding:1.25rem 1rem;border-radius:10px;text-align:center;transition:border-color .2s}.stat-card:hover{border-color:rgba(245,158,11,0.12)}.stat-value{font-size:1.75rem;font-weight:700;color:var(--primary-color);font-family:'Russo One',sans-serif}.stat-label{color:var(--text-secondary);margin-top:.35rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em}.profile-sections{display:grid;gap:2rem}.section h2{margin-bottom:1rem;font-size:1rem;text-transform:uppercase;letter-spacing:.06em;color:#c8c6d0}.items-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.75rem}.item-card{background:rgba(14,15,19,0.9);padding:.75rem .85rem;border-radius:8px;border-left:3px solid;border:1px solid var(--border-color);border-left-width:3px;transition:border-color .2s}.item-card:hover{border-color:rgba(255,255,255,0.08);border-left-width:4px}.item-name{font-weight:600;margin-bottom:.35rem;word-break:break-word;font-size:.8rem}.item-details{display:flex;justify-content:space-between;font-size:.7rem;color:var(--text-secondary)}.history-list{display:flex;flex-direction:column;gap:.4rem}.history-item{background:rgba(14,15,19,0.9);border:1px solid var(--border-color);padding:.75rem .85rem;border-radius:8px;display:flex;align-items:center;gap:.75rem;transition:border-color .2s}.history-item:hover{border-color:rgba(255,255,255,0.08)}.history-icon{font-size:1.25rem;flex-shrink:0}.history-content{flex:1;min-width:0}.history-title{font-weight:600;margin-bottom:.2rem;font-size:.85rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.history-meta{display:flex;gap:.75rem;font-size:.7rem;color:var(--text-secondary);flex-wrap:wrap}.empty-state{color:var(--text-secondary);text-align:center;padding:2rem;font-size:.85rem}.cases-container{padding:1.5rem 0}.cases-container h1{font-size:1.5rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem}.page-subtitle{color:var(--text-secondary);margin-bottom:1.5rem;font-size:.85rem}.cases-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1rem}.case-card{background:rgba(20,21,26,0.8);border:1px solid var(--border-color);padding:1.25rem 1rem;border-radius:10px;cursor:pointer;transition:all .25s}.case-card:hover{transform:translateY(-3px);border-color:rgba(245,158,11,0.15);box-shadow:0 8px 30px rgba(0,0,0,0.3)}.case-icon{font-size:2.5rem;text-align:center;margin-bottom:.75rem}.case-card h3{text-align:center;margin-bottom:.75rem;font-size:.9rem;text-transform:uppercase;letter-spacing:.04em}.case-stats{text-align:center;color:var(--text-secondary);margin-bottom:.5rem;font-size:.75rem}.case-rarities{display:flex;flex-wrap:wrap;gap:.2rem;justify-content:center;margin-bottom:.75rem}.rarity-badge{padding:.2rem .4rem;border-radius:3px;font-size:.65rem;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.modal{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content{background:rgba(20,21,26,0.95);border:1px solid var(--border-color);padding:1.5rem;border-radius:10px;text-align:center;min-width:300px;max-width:500px;backdrop-filter:blur(8px)}.case-animation{padding:1.5rem}.spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,0.06);border-top-color:var(--primary-color);border-radius:50%;animation:spin 1s linear infinite;margin:0 auto .75rem}@keyframes spin{to{transform:rotate(360deg)}}.case-result,.contract-result{padding:.75rem}.result-icon{font-size:3rem;margin-bottom:.75rem}.result-title{font-size:1.1rem;font-weight:700;margin-bottom:.5rem}.result-item-name{color:var(--text-secondary);font-size:.85rem}.contracts-container{padding:1.5rem 0}.contracts-info{margin-bottom:1.5rem}.info-card{background:rgba(20,21,26,0.8);border:1px solid var(--border-color);padding:1.25rem;border-radius:10px}.info-card h3{margin-bottom:.75rem;font-size:.95rem;text-transform:uppercase;letter-spacing:.05em}.info-card ol{padding-left:1.25rem;color:var(--text-secondary);font-size:.85rem}.info-card li{margin-bottom:.4rem}.contracts-groups{display:flex;flex-direction:column;gap:1.5rem}.contract-group{background:rgba(20,21,26,0.8);border:1px solid var(--border-color);border-radius:10px;overflow:hidden}.group-header{padding:1rem 1.25rem;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.group-header h3{display:flex;align-items:center;gap:.5rem;font-size:.85rem;text-transform:uppercase;letter-spacing:.04em}.group-type{padding:.2rem .45rem;background:rgba(0,0,0,0.3);border:1px solid var(--border-color);border-radius:4px;font-size:.7rem;color:var(--text-secondary)}.group-count{color:var(--text-secondary);font-size:.8rem}.group-items{padding:.75rem;display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:.4rem;max-height:400px;overflow-y:auto}.contract-item{background:rgba(14,15,19,0.9);border:1px solid var(--border-color);padding:.6rem .75rem;border-radius:8px;display:flex;align-items:center;gap:.75rem;cursor:pointer;transition:all .2s;border:2px solid transparent}.contract-item:hover{background:var(--bg-light)}.contract-item.selected{border-color:var(--primary-color);background:rgba(79,70,229,0.1)}.item-checkbox{width:20px;height:20px;border:2px solid var(--border-color);border-radius:4px;display:flex;align-items:center;justify-content:center}.contract-item.selected .item-checkbox{background:var(--primary-color);border-color:var(--primary-color)}.contract-item.selected .item-checkbox::after{content:"✓";color:white;font-size:14px}.contract-slot{background:rgba(0,0,0,0.3);border:1px solid #8b7355;border-radius:4px;padding:10px;min-height:70px}#contractScribble{width:100%;padding:10px;background:transparent;border:1px dashed #8b7355;color:#d4c5a9;font-family:'Brush Script MT',cursive;font-size:18px;text-align:center;margin-bottom:15px;outline:none}#contractScribble:focus{border-color:#d4c5a9}.contract-paper{background:#1a1a1a;background-image:linear-gradient(rgba(255,255,255,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.03) 1px,transparent 1px);background-size:20px 20px;border:2px solid #8b7355;border-radius:4px;padding:30px;position:relative;box-shadow:inset 0 0 50px rgba(0,0,0,0.5),0 10px 30px rgba(0,0,0,0.5)}.contract-paper::before{content:'';position:absolute;top:10px;left:10px;right:10px;bottom:10px;border:1px solid rgba(139,115,85,0.3);pointer-events:none}.contract-header{text-align:center;margin-bottom:30px;border-bottom:2px solid #8b7355;padding-bottom:15px}.contract-header h2{color:#d4c5a9;text-transform:uppercase;letter-spacing:3px;font-size:24px;text-shadow:2px 2px 4px rgba(0,0,0,0.5)}.contract-items-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:15px;margin:30px 0}.contract-slot.filled{background:rgba(139,115,85,0.2);border-color:#d4c5a9}.contract-slot-content{font-family:'Courier New',monospace;color:#d4c5a9;font-size:11px;line-height:1.4;word-break:break-word}.contract-signature-line{margin-top:30px;border-top:1px solid #8b7355;padding-top:20px;display:flex;justify-content:space-between;align-items:center}.contract-signature{font-family:'Brush Script MT',cursive;color:#8b7355;font-size:20px}.contract-stamp{width:80px;height:80px;border:3px double #8b7355;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#8b7355;transform:rotate(-15deg);opacity:0;transition:all .5s}.contract-stamp.active{opacity:1;transform:rotate(-15deg) scale(1.1)}.glow-consumer-grade{box-shadow:0 0 40px rgba(176,176,176,0.6)}.glow-industrial-grade{box-shadow:0 0 40px rgba(94,152,217,0.6)}.glow-mil-spec,.glow-mil-spec-grade{box-shadow:0 0 40px rgba(75,105,255,0.6)}.glow-restricted{box-shadow:0 0 40px rgba(136,71,255,0.6)}.glow-classified{box-shadow:0 0 40px rgba(211,44,230,0.6)}.glow-covert{box-shadow:0 0 40px rgba(235,75,75,0.6)}.glow-rare-special-item,.glow-extraordinary{box-shadow:0 0 40px rgba(255,215,0,0.6)}.item-info{flex:1;min-width:0}.item-info .item-name{font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item-meta{font-size:.8rem;color:var(--text-secondary)}.group-footer{padding:1rem 1.5rem;border-top:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.selected-count{color:var(--text-secondary)}.empty-state-large{text-align:center;padding:4rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state-large h3{margin-bottom:.5rem}.empty-state-large p{color:var(--text-secondary);margin-bottom:2rem}.rarity-consumer-grade{border-left-color:var(--rarity-consumer)}.rarity-industrial-grade{border-left-color:var(--rarity-industrial)}.rarity-mil-spec,.rarity-mil-spec-grade{border-left-color:var(--rarity-mil-spec)}.rarity-restricted{border-left-color:var(--rarity-restricted)}.rarity-classified{border-left-color:var(--rarity-classified)}.rarity-covert{border-left-color:var(--rarity-covert)}.rarity-rare-special-item,.rarity-extraordinary{border-left-color:var(--rarity-rare-special)}.rarity-contraband{border-left-color:var(--rarity-contraband)}.rarity-consumer-grade{color:var(--rarity-consumer)}.rarity-industrial-grade{color:var(--rarity-industrial)}.rarity-mil-spec,.rarity-mil-spec-grade{color:var(--rarity-mil-spec)}.rarity-restricted{color:var(--rarity-restricted)}.rarity-classified{color:var(--rarity-classified)}.rarity-covert{color:var(--rarity-covert)}.rarity-rare-special-item,.rarity-extraordinary{color:var(--rarity-rare-special)}.rarity-badge.rarity-consumer-grade{background:var(--rarity-consumer);color:black}.rarity-badge.rarity-industrial-grade{background:var(--rarity-industrial);color:white}.rarity-badge.rarity-mil-spec{background:var(--rarity-mil-spec);color:white}.rarity-badge.rarity-restricted{background:var(--rarity-restricted);color:white}.rarity-badge.rarity-classified{background:var(--rarity-classified);color:white}.rarity-badge.rarity-covert{background:var(--rarity-covert);color:white}.rarity-badge.rarity-rare-special-item{background:var(--rarity-rare-special);color:black}.inventory-container{padding:1.5rem 0}.inventory-filters{display:flex;gap:.75rem;margin-bottom:1rem;flex-wrap:wrap}.filter-select{padding:.4rem .7rem;background:rgba(0,0,0,0.3);border:1px solid var(--border-color);border-radius:6px;color:var(--text-primary);cursor:pointer;font-family:'Rajdhani',sans-serif;font-size:.8rem;outline:none}.filter-select:focus{border-color:rgba(245,158,11,0.3)}.inventory-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.85rem}.inventory-grid-detailed{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px}.inventory-skin-card{background:rgba(20,21,26,0.8);border:1px solid var(--border-color);border-radius:8px;padding:10px;cursor:pointer;transition:all .25s;border:1px solid var(--border-color);position:relative}.inventory-skin-card:hover{border-color:rgba(245,158,11,0.15)}.inventory-item{background:rgba(20,21,26,0.8);border:1px solid var(--border-color);border-radius:8px;overflow:hidden}.inventory-item-header{padding:.65rem .75rem;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.inventory-skin-card.selected{border-color:var(--primary-color);box-shadow:0 0 15px rgba(245,158,11,0.1)}.inventory-skin-card .skin-image-container{width:100%;height:110px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;background:rgba(0,0,0,0.2);border-radius:4px}.inventory-skin-card .skin-image{max-width:100%;max-height:110px;object-fit:contain}.inventory-skin-card .item-checkbox{position:absolute;top:8px;right:8px;width:20px;height:20px;border:2px solid var(--border-color);border-radius:4px;background:var(--bg-dark)}.inventory-skin-card.selected .item-checkbox{background:var(--primary-color);border-color:var(--primary-color)}.inventory-skin-card.selected .item-checkbox::after{content:"✓";color:white;font-size:14px;display:flex;align-items:center;justify-content:center;width:100%;height:100%}.inventory-item-body{padding:.75rem}.inventory-item-footer{padding:.75rem;border-top:1px solid var(--border-color);font-size:.85rem;color:var(--text-secondary)}.item-type-badge{padding:.25rem .5rem;background:var(--bg-light);border-radius:4px;font-size:.75rem}.skin-info{font-size:12px}.skin-name{font-weight:500;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.skin-details{display:flex;justify-content:space-between;color:var(--text-secondary);font-size:11px}.case-opening-modal{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.95);display:flex;align-items:center;justify-content:center;z-index:1000;pointer-events:all}.case-scroll-container{width:100%;max-width:900px;height:280px;position:relative;overflow:hidden;background:linear-gradient(135deg,#1a1a2e 0,#16213e 100%);border-radius:12px;border:2px solid var(--border-color);margin:0 auto}.case-scroll-track{display:flex;position:absolute;top:5%;transform:translateY(50%);will-change:left;transition:none;left:0}.scroll-item{width:200px;height:240px;margin:0 10px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--bg-card);border-radius:8px;padding:15px;border:2px solid transparent;box-sizing:border-box}.scroll-item img{width:140px;height:110px;object-fit:contain;margin-bottom:10px;pointer-events:none}.scroll-item-name{font-size:11px;text-align:center;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;width:100%}.scroll-item-rarity{font-size:10px;margin-top:5px;text-align:center}.case-center-indicator{position:absolute;left:50%;top:0;bottom:0;width:4px;background:linear-gradient(to bottom,transparent,#ffd700,#ffd700,transparent);transform:translateX(-50%);z-index:10;box-shadow:0 0 30px #ffd700;pointer-events:none}.case-center-indicator::before,.case-center-indicator::after{content:'';position:absolute;left:-8px;width:20px;height:20px;background:#ffd700;clip-path:polygon(50% 0,0% 100%,100% 100%)}.case-center-indicator::before{top:-2px;transform:rotate(180deg)}.case-center-indicator::after{bottom:-2px}.case-result-display{margin-top:20px;text-align:center;animation:fadeInUp .5s}.case-card-link{text-decoration:none;color:inherit;display:block}.case-card-link:hover .case-card{transform:translateY(-4px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.skin-image{width:100%;height:auto;border-radius:4px;margin-bottom:8px}.skin-card{position:relative;overflow:hidden;transition:all .3s}.skin-card:hover{transform:translateY(-2px)}.skin-card .skin-overlay{position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,transparent 0,rgba(255,255,255,0.1) 100%);opacity:0;transition:opacity .3s;pointer-events:none}.skin-card:hover .skin-overlay{opacity:1}@keyframes fadeInUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}@keyframes typewriter{from{width:0}to{width:100%}}@keyframes blink{0%,100%{border-color:transparent}50%{border-color:#d4c5a9}}.typewriter-text{overflow:hidden;border-right:2px solid #d4c5a9;white-space:nowrap;animation:typewriter 1s steps(40) 1,blink .75s step-end infinite}.modal-locked{pointer-events:all}.modal-locked .modal-content{pointer-events:auto}.modal-loading{cursor:wait}.spinner-large{width:60px;height:60px;border:5px solid var(--border-color);border-top-color:var(--primary-color);border-radius:50%;animation:spin 1s linear infinite;margin:20px auto}.case-detail-container{padding:1rem 0;width:100%;overflow-x:hidden}.back-link{color:var(--text-secondary);text-decoration:none;display:inline-block;margin-bottom:.75rem;font-size:.9rem}.back-link:hover{color:var(--text-primary)}.case-header{display:flex;flex-direction:column;margin-bottom:1rem}.case-title-row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem}.case-title-row h1{font-size:1.5rem;margin:0;word-break:break-word}.case-info-badge{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.case-price-tag{background:var(--bg-card);padding:.4rem .8rem;border-radius:8px;border:1px solid var(--success-color);color:var(--success-color);font-weight:bold;font-size:.9rem}.case-subtitle{color:var(--text-secondary);font-size:.85rem}.opening-controls{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;background:var(--bg-card);padding:.75rem 1rem;border-radius:12px}.total-price-display{font-size:1rem;font-weight:bold;color:var(--success-color);margin-left:auto}.total-price-display span{color:var(--text-secondary);font-size:.8rem;font-weight:normal}.count-selector{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.count-selector label{color:var(--text-secondary);font-size:.85rem}.count-buttons{display:flex;gap:.3rem;flex-wrap:wrap}.count-btn{padding:.3rem .7rem;background:var(--bg-dark);border:1px solid var(--border-color);color:var(--text-primary);border-radius:6px;cursor:pointer;font-size:.8rem;transition:all .2s;min-width:40px}.count-btn.active{background:var(--primary-color);border-color:var(--primary-color)}.case-opening-section{background:var(--bg-card);border-radius:12px;padding:1rem;margin-bottom:1rem}.opening-animation-container{width:100%}.scroll-speed-lines{position:absolute;inset:0;z-index:5;pointer-events:none;background:repeating-linear-gradient(90deg,transparent 0,rgba(255,255,255,0.015) 2px,transparent 4px,transparent 40px);animation:speedLinesMove .4s linear infinite}@keyframes speedLinesMove{from{transform:translateX(0)}to{transform:translateX(40px)}}.scroll-flash{position:absolute;inset:0;z-index:15;pointer-events:none;opacity:0;transition:opacity .1s;border-radius:6px}.scroll-flash.burst{opacity:1}.scroll-flash.gold{background:radial-gradient(ellipse at center,rgba(255,215,0,0.4) 0,transparent 70%)}.scroll-flash.blue{background:radial-gradient(ellipse at center,rgba(75,105,255,0.4) 0,transparent 70%)}.scroll-flash.purple{background:radial-gradient(ellipse at center,rgba(136,71,255,0.4) 0,transparent 70%)}.scroll-flash.pink{background:radial-gradient(ellipse at center,rgba(211,44,230,0.4) 0,transparent 70%)}.scroll-flash.red{background:radial-gradient(ellipse at center,rgba(235,75,75,0.4) 0,transparent 70%)}.particle-burst{position:absolute;inset:0;z-index:20;pointer-events:none;overflow:hidden}.particle-burst .pt{position:absolute;width:4px;height:4px;border-radius:50%;animation:ptFly .8s ease-out forwards}@keyframes ptFly{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--px),var(--py)) scale(0)}}.slot-machine{position:relative;width:100%;max-width:660px;margin:0 auto;padding:20px 10px 10px;background:linear-gradient(135deg,#1a1a2e 0,#0d0d1a 100%);border-radius:16px;border:2px solid rgba(255,255,255,0.06);box-shadow:0 0 60px rgba(0,0,0,0.5),inset 0 0 40px rgba(0,0,0,0.3)}.slot-reels{display:flex;justify-content:center;gap:8px;position:relative;z-index:2}.slot-reel{width:160px;height:240px;overflow:hidden;border-radius:8px;background:rgba(0,0,0,0.4);border:2px solid rgba(255,255,255,0.08);position:relative}.slot-track{display:flex;flex-direction:column;will-change:transform}.slot-item{width:100%;height:80px;display:flex;flex-direction:column;align-items:center;justify-content:center;border-bottom:1px solid rgba(255,255,255,0.04);border-left:3px solid;box-sizing:border-box;padding:4px;transition:background .3s,box-shadow .3s;flex-shrink:0}.slot-item img{width:50px;height:40px;object-fit:contain;pointer-events:none}.slot-item-name{font-size:8px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;margin-top:2px;color:var(--text-secondary)}.slot-item-hit{background:rgba(255,215,0,0.12);box-shadow:inset 0 0 20px rgba(255,215,0,0.15)}.slot-win-line{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:calc(100% - 40px);height:80px;border:2px solid rgba(255,215,0,0.5);border-radius:6px;z-index:5;pointer-events:none;box-shadow:0 0 20px rgba(255,215,0,0.15),inset 0 0 20px rgba(255,215,0,0.05)}.slot-win-line::before,.slot-win-line::after{content:'';position:absolute;left:-4px;width:calc(100%+8px);height:2px;background:linear-gradient(90deg,transparent,#ffd700,transparent)}.slot-win-line::before{top:-2px}.slot-win-line::after{bottom:-2px}.slot-frame-glow{position:absolute;inset:-4px;border-radius:18px;border:3px solid transparent;z-index:1;pointer-events:none;transition:all .8s}.slot-result-display{text-align:center;margin-top:12px;position:relative;z-index:10}.slot-win-banner,.slot-lose-banner{padding:12px 20px;border-radius:10px}.slot-win-banner{background:rgba(255,215,0,0.1);border:1px solid rgba(255,215,0,0.3);animation:slotBannerIn .5s ease-out}.slot-lose-banner{background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.2);animation:slotBannerIn .5s ease-out}.slot-win-title{font-family:'Russo One',sans-serif;font-size:1.4rem;color:#ffd700;text-shadow:0 0 20px rgba(255,215,0,0.4);letter-spacing:.06em}.slot-win-subtitle,.slot-lose-subtitle{font-size:.85rem;color:var(--text-secondary);margin-top:4px}.slot-lose-title{font-family:'Russo One',sans-serif;font-size:1.1rem;color:var(--danger-color)}@keyframes slotBannerIn{from{opacity:0;transform:translateY(20px) scale(0.95)}to{opacity:1;transform:translateY(0) scale(1)}}.slot-badge{position:absolute;top:2px;right:2px;font-size:.6rem;line-height:1;z-index:5;pointer-events:none;filter:drop-shadow(0 0 3px rgba(0,0,0,0.8))}.slot-badge-extra_spin,.slot-badge-free_spin,.slot-badge-guaranteed,.slot-badge-double,.slot-badge-quintuple{padding:1px 3px;border-radius:3px;font-size:.55rem;font-weight:700}.slot-badge-extra_spin{color:#8b5cf6}.slot-badge-free_spin{color:#10b981}.slot-badge-guaranteed{color:#3b82f6}.slot-badge-double{color:#f59e0b}.slot-badge-quintuple{color:#ef4444}.slot-legend{margin-top:14px;padding:16px;background:linear-gradient(135deg,rgba(30,28,45,0.98) 0,rgba(25,23,40,0.98) 100%);border:1px solid rgba(255,255,255,0.12);border-radius:10px;position:relative;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,0.06),0 4px 24px rgba(0,0,0,0.5)}.slot-legend::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,rgba(245,158,11,0.5),transparent)}.slot-legend::after{content:'';position:absolute;bottom:0;left:25%;right:25%;height:1px;background:linear-gradient(90deg,transparent,rgba(245,158,11,0.2),transparent)}.slot-legend-header{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:14px;position:relative}.slot-legend-header::after{content:'';position:absolute;bottom:-6px;left:30%;right:30%;height:1px;background:linear-gradient(90deg,transparent,rgba(245,158,11,0.25),transparent)}.slot-legend-symbol{font-size:.6rem;color:rgba(245,158,11,0.4);letter-spacing:6px}.slot-legend-label{font-family:'Russo One',sans-serif;font-size:.7rem;color:rgba(245,158,11,0.7);text-transform:uppercase;letter-spacing:.2em}.slot-legend-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}.slot-legend-card{display:flex;flex-direction:column;align-items:center;gap:5px;padding:12px 4px 10px;border-radius:8px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);cursor:default;transition:background .25s,border-color .25s,transform .2s,box-shadow .25s;position:relative}.slot-legend-card:hover{background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.18);transform:translateY(-3px);box-shadow:0 4px 16px rgba(0,0,0,0.4)}.slot-legend-card[data-act="extra_spin"]{background:rgba(139,92,246,0.1);border-color:rgba(139,92,246,0.18)}.slot-legend-card[data-act="extra_spin"]:hover{background:rgba(139,92,246,0.18);border-color:rgba(139,92,246,0.3);box-shadow:0 4px 20px rgba(139,92,246,0.2)}.slot-legend-card[data-act="double"]{background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.18)}.slot-legend-card[data-act="double"]:hover{background:rgba(245,158,11,0.18);border-color:rgba(245,158,11,0.3);box-shadow:0 4px 20px rgba(245,158,11,0.2)}.slot-legend-card[data-act="quintuple"]{background:rgba(239,68,68,0.1);border-color:rgba(239,68,68,0.18)}.slot-legend-card[data-act="quintuple"]:hover{background:rgba(239,68,68,0.18);border-color:rgba(239,68,68,0.3);box-shadow:0 4px 20px rgba(239,68,68,0.2)}.slot-legend-card[data-act="guaranteed"]{background:rgba(59,130,246,0.1);border-color:rgba(59,130,246,0.18)}.slot-legend-card[data-act="guaranteed"]:hover{background:rgba(59,130,246,0.18);border-color:rgba(59,130,246,0.3);box-shadow:0 4px 20px rgba(59,130,246,0.2)}.slot-legend-card[data-act="free_spin"]{background:rgba(16,185,129,0.1);border-color:rgba(16,185,129,0.18)}.slot-legend-card[data-act="free_spin"]:hover{background:rgba(16,185,129,0.18);border-color:rgba(16,185,129,0.3);box-shadow:0 4px 20px rgba(16,185,129,0.2)}.slc-icon{font-size:1.1rem;line-height:1;font-family:'Russo One',sans-serif;font-weight:700;filter:drop-shadow(0 0 8px currentColor);transition:transform .25s,filter .25s}.slot-legend-card:hover .slc-icon{transform:scale(1.2);filter:drop-shadow(0 0 14px currentColor)}.slc-name{font-family:'Russo One',sans-serif;font-size:.6rem;color:#fff;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap;transition:color .2s}.slc-desc{font-family:'Rajdhani',sans-serif;font-size:.6rem;color:rgba(255,255,255,0.5);text-align:center;line-height:1.2;transition:color .25s}.slot-legend-card:hover .slc-desc{color:rgba(255,255,255,0.75)}.scroll-container-full{position:relative;width:100%;height:220px;max-height:220px;overflow:hidden;background:linear-gradient(135deg,#1a1a2e 0,#16213e 100%);border-radius:8px;border:2px solid var(--border-color);box-shadow:inset 0 0 60px rgba(0,0,0,0.4),0 0 30px rgba(0,0,0,0.3)}.scroll-container-full::before{content:'';position:absolute;top:0;left:0;right:0;height:30px;background:linear-gradient(to bottom,rgba(0,0,0,0.7),transparent);z-index:8;pointer-events:none}.scroll-container-full::after{content:'';position:absolute;bottom:0;left:0;right:0;height:30px;background:linear-gradient(to top,rgba(0,0,0,0.7),transparent);z-index:8;pointer-events:none}.target-highlight-ring{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:130px;height:200px;border:2px solid transparent;border-radius:10px;z-index:9;pointer-events:none;transition:all .3s}.target-highlight-ring.glow{border-color:#ffd700;box-shadow:0 0 30px rgba(255,215,0,0.3),inset 0 0 30px rgba(255,215,0,0.1)}.scroll-track-full{display:flex;position:absolute;top:0;left:0;height:100%;will-change:transform;transform:translateZ(0);backface-visibility:hidden}.scroll-item-full{width:120px;height:190px;margin:8px 8px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;background:var(--bg-card);border-radius:8px;padding:8px 4px;border:2px solid transparent;box-sizing:border-box}.scroll-item-full img{width:80px;height:70px;object-fit:contain}.scroll-item-name-full{font-size:9px;text-align:center;margin-top:4px;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;width:100%}.scroll-item-rarity-full{font-size:8px;margin-top:3px;font-weight:bold}.center-indicator-full{position:absolute;left:50%;top:0;bottom:0;width:3px;background:linear-gradient(to bottom,transparent,#ffd700,#ffd700,transparent);transform:translateX(-50%);z-index:10;box-shadow:0 0 20px #ffd700;pointer-events:none}.multi-scroll-grid{display:grid;gap:.75rem;width:100%}.scroll-wrapper-full{width:100%;text-align:center}.scroll-container-mini{width:100%;height:150px;max-height:150px;position:relative;overflow:hidden;background:linear-gradient(135deg,#1a1a2e 0,#16213e 100%);border-radius:8px;border:2px solid var(--border-color);box-shadow:inset 0 0 30px rgba(0,0,0,0.3),0 0 15px rgba(0,0,0,0.2)}.scroll-container-mini::before{content:'';position:absolute;top:0;left:0;right:0;height:20px;background:linear-gradient(to bottom,rgba(0,0,0,0.6),transparent);z-index:8;pointer-events:none}.scroll-container-mini::after{content:'';position:absolute;bottom:0;left:0;right:0;height:20px;background:linear-gradient(to top,rgba(0,0,0,0.6),transparent);z-index:8;pointer-events:none}.mini-target-ring{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:80px;height:140px;border:2px solid transparent;border-radius:6px;z-index:9;pointer-events:none;transition:all .3s}.mini-target-ring.glow{border-color:#ffd700;box-shadow:0 0 20px rgba(255,215,0,0.25),inset 0 0 20px rgba(255,215,0,0.08)}.scroll-track-mini{display:flex;position:absolute;top:0;left:0;height:100%;will-change:transform;transform:translateZ(0)}.scroll-item-mini{width:70px;height:130px;margin:6px 6px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;background:var(--bg-card);border-radius:6px;padding:5px 3px;border:2px solid transparent;box-sizing:border-box}.scroll-item-mini img{width:45px;height:45px;object-fit:contain}.scroll-item-name-mini{font-size:7px;text-align:center;margin-top:3px;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.center-indicator-mini{position:absolute;left:50%;top:0;bottom:0;width:2px;background:#ffd700;transform:translateX(-50%);z-index:10;box-shadow:0 0 15px #ffd700;pointer-events:none}.scroll-label{margin-top:.3rem;font-size:.7rem;color:var(--text-secondary)}.results-grid-compact{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.75rem}.result-item-compact{background:var(--bg-dark);padding:.4rem;border-radius:6px;display:flex;align-items:center;gap:.4rem;border-left:3px solid;flex:1 0 auto;min-width:160px}.result-item-compact img{width:35px;height:30px;object-fit:contain}.case-results-header{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem}.case-results-title{font-family:'Russo One',sans-serif;font-size:1rem;text-transform:uppercase;letter-spacing:.03em}.case-results-actions{display:flex;gap:.4rem}.results-grid-enhanced{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:.5rem}.result-card-enhanced{background:var(--bg-dark);border-radius:8px;overflow:hidden;position:relative;border:1px solid var(--border-color);transition:all .25s;opacity:0;animation:fadeInUp .4s ease-out forwards}.result-card-enhanced:hover{transform:translateY(-2px);border-color:var(--glow,var(--primary-color));box-shadow:0 4px 16px rgba(0,0,0,0.3)}.result-card-glow{position:absolute;inset:0;opacity:.3;pointer-events:none}.result-card-enhanced img{width:100%;height:70px;object-fit:contain;padding:.5rem .5rem 0;display:block;position:relative;z-index:1}.result-card-body{padding:.35rem .5rem .4rem;position:relative;z-index:1}.result-name-enhanced{font-size:.65rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:.1rem}.result-meta-enhanced{font-size:.55rem;color:var(--text-secondary);margin-bottom:.25rem}.result-footer-enhanced{display:flex;justify-content:space-between;align-items:center}.result-price-enhanced{font-size:.75rem;color:var(--success-color);font-weight:bold}.result-count-badge{position:absolute;top:4px;right:4px;background:var(--primary-color);color:white;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;z-index:2}.result-info-compact{flex:1;min-width:0}.result-name-compact{font-size:10px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.result-meta-compact{display:flex;gap:.5rem;font-size:9px;color:var(--text-secondary);flex-wrap:wrap}.result-count-badge{background:var(--primary-color);color:white;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;margin-left:3px}.btn-sell-result{padding:2px 6px;background:transparent;border:1px solid var(--danger-color);color:var(--danger-color);border-radius:4px;cursor:pointer;font-size:8px}.btn-sell-result:hover{background:var(--danger-color);color:white}.case-items-section{margin-top:1rem}.case-items-section h2{font-size:1.2rem;margin-bottom:.75rem}.rarity-filters{display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.75rem}.filter-btn{padding:.25rem .6rem;background:var(--bg-card);border:1px solid var(--border-color);color:var(--text-secondary);border-radius:20px;cursor:pointer;font-size:.7rem;transition:all .2s}.filter-btn.active{background:var(--primary-color);color:white;border-color:var(--primary-color)}.filter-btn:hover{background:var(--bg-light)}.rarity-section{margin-bottom:.75rem}.rarity-section h3{font-size:.9rem;margin-bottom:.4rem;padding-bottom:.2rem;border-bottom:2px solid;display:flex;align-items:center;gap:.4rem}.rarity-count{font-size:.7rem;color:var(--text-secondary);font-weight:normal}.items-grid-compact{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.4rem}.item-card-compact{background:var(--bg-card);border-radius:6px;padding:.4rem;border-left:3px solid;cursor:pointer;transition:all .2s}.item-card-compact:hover{transform:translateY(-1px);background:var(--bg-light)}.item-card-compact img{width:100%;height:60px;object-fit:contain;margin-bottom:.2rem}.item-name-compact{font-size:9px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:.2rem}.item-meta-compact{display:flex;justify-content:space-between;font-size:8px;color:var(--text-secondary);flex-wrap:wrap}.item-price-compact{font-size:9px;font-weight:bold;color:var(--success-color);margin-top:.2rem}.upgrade-container{padding:.75rem 0;width:100%;overflow-x:hidden}.upgrade-header{text-align:center;margin-bottom:1rem}.upgrade-header h1{font-size:1.5rem;background:linear-gradient(135deg,#ffd700,#ff6b6b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.25rem}.upgrade-header p{font-size:.85rem;color:var(--text-secondary)}.upgrade-layout{display:grid;grid-template-columns:280px 1fr 280px;gap:1rem;margin-bottom:1.5rem}.inventory-panel,.target-panel{background:var(--bg-card);border-radius:12px;border:1px solid var(--border-color);display:flex;flex-direction:column;max-height:500px}.panel-header,.target-header{padding:.75rem;border-bottom:1px solid var(--border-color);background:var(--bg-card);border-radius:12px 12px 0 0}.panel-title{font-size:.95rem;font-weight:600;display:flex;justify-content:space-between;align-items:center}.inventory-list,.target-list{flex:1;overflow-y:auto;padding:.4rem;display:flex;flex-direction:column;gap:.4rem}.inventory-item-card,.target-item-card{background:var(--bg-dark);border-radius:8px;padding:.5rem;display:flex;align-items:center;gap:.5rem;cursor:pointer;border:2px solid transparent;transition:all .2s}.inventory-item-card:hover,.target-item-card:hover{background:var(--bg-light);border-color:var(--primary-color)}.inventory-item-card.selected,.target-item-card.selected{border-color:#ffd700;background:rgba(255,215,0,0.1);box-shadow:0 0 15px rgba(255,215,0,0.2)}.inventory-item-card.disabled,.target-item-card.disabled{opacity:.5;cursor:not-allowed;pointer-events:none}.inventory-item-card img,.target-item-card img{width:45px;height:40px;object-fit:contain;background:var(--bg-card);border-radius:4px;padding:3px}.inventory-item-info,.target-item-info{flex:1;min-width:0}.inventory-item-name,.target-item-name{font-size:.75rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:.15rem}.inventory-item-meta,.target-item-meta{display:flex;justify-content:space-between;font-size:.65rem;color:var(--text-secondary)}.inventory-item-price,.target-item-price{color:var(--success-color);font-weight:bold}.wheel-panel{background:var(--bg-card);border-radius:12px;padding:1rem;border:1px solid var(--border-color);display:flex;flex-direction:column;align-items:center}.selected-items-display{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:.75rem;width:100%}.selected-item-mini{text-align:center;flex:1;min-width:0}.selected-item-mini img{width:60px;height:50px;object-fit:contain;background:var(--bg-dark);border-radius:6px;padding:5px}.selected-item-mini-name{font-size:.65rem;margin-top:.2rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.selected-item-mini-price{font-size:.7rem;color:var(--success-color);font-weight:bold}.arrow-icon{font-size:1.2rem;color:var(--primary-color)}.wheel-container{position:relative;width:220px;height:220px;margin:.5rem 0}.wheel{width:100%;height:100%;border-radius:50%;background:conic-gradient(from 0deg,#10b981 0deg,#10b981 calc(var(--probability) * 3.6deg / 2),#2d1b1b calc(var(--probability) * 3.6deg / 2),#2d1b1b calc(360deg - var(--probability) * 3.6deg / 2),#10b981 calc(360deg - var(--probability) * 3.6deg / 2),#10b981 360deg);border:3px solid var(--border-color);box-shadow:0 0 30px rgba(0,0,0,0.5)}.wheel-pointer{position:absolute;top:-10px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:12px solid transparent;border-right:12px solid transparent;border-top:24px solid #ffd700;filter:drop-shadow(0 0 6px #ffd700);z-index:10}.wheel-center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:40px;height:40px;background:var(--bg-card);border-radius:50%;border:2px solid #ffd700;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:.8rem;color:#ffd700;z-index:5}.upgrade-actions{display:flex;align-items:center;gap:.5rem;margin-top:.5rem}.upgrade-btn{padding:.6rem 1.5rem;font-size:1rem;flex:1}.quick-btn{width:38px;height:38px;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-dark);color:var(--text-secondary);font-size:1.1rem;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;flex-shrink:0}.quick-btn:hover{border-color:var(--primary-color);color:var(--primary-color)}.quick-btn.active{background:var(--primary-color);color:#fff;border-color:var(--primary-color);box-shadow:0 0 12px rgba(245,158,11,0.4)}.inv-filter-bar{display:flex;gap:.3rem;align-items:center;margin-top:.4rem}.inv-search-input{flex:1;padding:.35rem .5rem;background:var(--bg-dark);border:1px solid var(--border-color);border-radius:5px;color:var(--text-primary);font-size:.75rem;min-width:0}.inv-search-input:focus{outline:none;border-color:var(--primary-color)}.inv-sort-group{display:flex;gap:2px;flex-shrink:0}.inv-sort-btn{padding:.3rem .4rem;background:var(--bg-dark);border:1px solid var(--border-color);color:var(--text-secondary);border-radius:4px;cursor:pointer;font-size:.7rem;transition:all .15s;line-height:1}.inv-sort-btn.active{background:var(--primary-color);color:white;border-color:var(--primary-color)}.inv-sort-btn:hover{border-color:var(--primary-color)}.target-search-input{width:100%;padding:.5rem;background:var(--bg-dark);border:1px solid var(--border-color);border-radius:6px;color:var(--text-primary);margin-bottom:.4rem;font-size:.85rem}.target-search-input:focus{outline:none;border-color:var(--primary-color)}.sort-controls{display:flex;gap:.3rem}.sort-btn{flex:1;padding:.3rem;background:var(--bg-dark);border:1px solid var(--border-color);color:var(--text-secondary);border-radius:4px;cursor:pointer;font-size:.65rem}.sort-btn.active{background:var(--primary-color);color:white;border-color:var(--primary-color)}.history-section{background:var(--bg-card);border-radius:12px;padding:1rem;border:1px solid var(--border-color)}.history-title{font-size:1rem;margin-bottom:.75rem;display:flex;align-items:center;gap:.4rem}.history-count{font-size:.65rem;color:var(--text-secondary);background:var(--bg-dark);padding:.1rem .45rem;border-radius:10px;font-weight:normal}.history-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.5rem}.history-card{background:var(--bg-dark);border-radius:8px;padding:.4rem .5rem;display:flex;align-items:center;gap:.4rem;transition:all .2s}.history-card:hover{background:var(--bg-light)}.history-card.success{border-left:3px solid #10b981}.history-card.fail{border-left:3px solid #ef4444}.history-card img{width:36px;height:30px;object-fit:contain;flex-shrink:0}.history-info{flex:1;min-width:0}.history-names{font-size:.7rem;margin-bottom:.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.history-meta{display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-secondary)}.history-result{font-weight:bold}.history-result.success{color:#10b981}.history-result.fail{color:#ef4444}.cases-sections{display:flex;flex-direction:column;gap:1.5rem}.section-tab{font-size:1.2rem;margin-bottom:.75rem;padding-bottom:.4rem;border-bottom:2px solid var(--primary-color);display:inline-block}.section-cases{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem}.case-card-new{background:var(--bg-card);border-radius:10px;overflow:hidden;transition:all .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:120px;background:linear-gradient(135deg,#1a1a2e 0,#16213e 100%);display:flex;align-items:center;justify-content:center;position:relative}.case-image img{max-width:70%;max-height:90px;object-fit:contain}.case-price-badge{position:absolute;top:8px;right:8px;background:var(--bg-dark);padding:.2rem .5rem;border-radius:20px;font-size:.75rem;font-weight:bold;color:var(--success-color);border:1px solid var(--success-color)}.case-info{padding:.75rem}.case-title{font-size:.95rem;font-weight:600;margin-bottom:.3rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.case-description{font-size:.75rem;color:var(--text-secondary);margin-bottom:.5rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.case-stats{display:flex;justify-content:space-between;font-size:.7rem;color:var(--text-secondary);margin-bottom:.5rem}.case-rarities-mini{display:flex;gap:.2rem;flex-wrap:wrap}.rarity-dot{width:8px;height:8px;border-radius:50%}.case-footer{display:flex;align-items:center;justify-content:space-between;padding-top:.5rem;border-top:1px solid var(--border-color)}.case-price{font-weight:bold;color:var(--success-color);font-size:.85rem}.btn-open{padding:.3rem .8rem;background:var(--primary-color);color:white;border:none;border-radius:5px;cursor:pointer;font-size:.75rem}.btn-open:hover{background:var(--primary-hover)}@media(max-width:1000px){.upgrade-layout{grid-template-columns:1fr;gap:.75rem}.inventory-panel,.target-panel{max-height:350px}.wheel-container{width:200px;height:200px}}@media(max-width:768px){.container{padding:0 12px}.navbar .container{flex-direction:column;align-items:stretch}.logo{text-align:center}.nav-links{justify-content:center;gap:.25rem}.nav-link{padding:.4rem .5rem;font-size:.8rem}.user-balance{font-size:.8rem;padding:.4rem .5rem}.hero h1{font-size:2rem}.hero-subtitle{font-size:1rem}.btn{padding:.4rem .8rem;font-size:.85rem}.btn-large{padding:.6rem 1.2rem}.case-title-row h1{font-size:1.2rem}.opening-controls{flex-direction:column;align-items:stretch}.total-price-display{margin-left:0;text-align:center}.count-selector{justify-content:center}.count-buttons{justify-content:center}.scroll-container-full{height:190px}.scroll-item-full{width:100px;height:165px;margin:5px 5px}.scroll-item-full img{width:65px;height:55px}.scroll-container-mini{height:130px}.scroll-item-mini{width:60px;height:115px;margin:4px 4px}.scroll-item-mini img{width:40px;height:40px}.items-grid-compact{grid-template-columns:repeat(3,1fr)}.multi-scroll-grid{grid-template-columns:repeat(2,1fr)!important}.contract-items-grid{grid-template-columns:repeat(2,1fr)}.case-scroll-container{height:250px}.scroll-item{width:150px;height:200px}.scroll-item img{width:100px;height:80px}.stats-grid{grid-template-columns:repeat(2,1fr)}.cases-grid{grid-template-columns:1fr}.group-items{grid-template-columns:1fr}.group-header{flex-direction:column;gap:.5rem;text-align:center}.group-footer{flex-direction:column;gap:1rem}.section-cases{grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.75rem}.case-image{height:100px}.case-title{font-size:.85rem}.case-description{font-size:.7rem}}@media(max-width:480px){.nav-links{flex-wrap:wrap;justify-content:center}.nav-link{font-size:.75rem;padding:.3rem .4rem}.hero h1{font-size:1.5rem}.case-title-row{flex-direction:column;align-items:flex-start}.scroll-container-full{height:170px}.scroll-item-full{width:90px;height:150px}.scroll-item-full img{width:55px;height:45px}.scroll-item-name-full{font-size:8px}.scroll-container-mini{height:115px}.scroll-item-mini{width:55px;height:100px}.items-grid-compact{grid-template-columns:repeat(2,1fr)}.result-item-compact{min-width:130px}.upgrade-header h1{font-size:1.3rem}.wheel-container{width:180px;height:180px}.wheel-center{width:35px;height:35px;font-size:.7rem}.selected-item-mini img{width:50px;height:40px}.history-grid{grid-template-columns:1fr}.cases-container h1{font-size:1.3rem}.section-cases{grid-template-columns:1fr 1fr}.case-image{height:90px}.case-info{padding:.5rem}}@media(max-width:768px){body{font-size:14px}input,button,select,textarea{font-size:16px!important}html,body{max-width:100%;overflow-x:hidden}img{max-width:100%;height:auto}}body{font-family:'Rajdhani',sans-serif}h1,h2,h3,h4,h5,h6,.logo,.nav-link,.btn{font-family:'Rajdhani',sans-serif}.upgrade-page{font-family:'Rajdhani',sans-serif;background:radial-gradient(ellipse at 50% 0,#14161f 0,#0a0b0e 100%);min-height:100vh}.upgrade-page .upgrade-header h1{font-family:'Russo One',sans-serif;font-size:1.75rem;letter-spacing:.05em;text-transform:uppercase;background:linear-gradient(135deg,#f59e0b 0,#d97706 50%,#f59e0b 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;text-shadow:none;filter:drop-shadow(0 0 20px rgba(245,158,11,0.3));margin-bottom:.25rem}.upgrade-page .upgrade-header p{color:#8a8a9a;font-size:.9rem;letter-spacing:.03em}.upgrade-page .upgrade-layout{display:grid;grid-template-columns:280px 1fr 280px;gap:1rem;margin-bottom:1.5rem}.upgrade-page .panel-title{font-family:'Russo One',sans-serif;font-size:.8rem;letter-spacing:.08em;text-transform:uppercase;color:#c8c6d0}.upgrade-page .inventory-panel,.upgrade-page .target-panel{background:rgba(20,21,26,0.8);backdrop-filter:blur(8px);border-radius:10px;border:1px solid rgba(255,255,255,0.06);display:flex;flex-direction:column;max-height:520px;overflow:hidden}.upgrade-page .panel-header,.upgrade-page .target-header{padding:.65rem .75rem;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0}.upgrade-page .inventory-list,.upgrade-page .target-list{flex:1;overflow-y:auto;padding:.4rem;display:flex;flex-direction:column;gap:.35rem}.upgrade-page .inventory-list::-webkit-scrollbar,.upgrade-page .target-list::-webkit-scrollbar{width:4px}.upgrade-page .inventory-list::-webkit-scrollbar-track,.upgrade-page .target-list::-webkit-scrollbar-track{background:transparent}.upgrade-page .inventory-list::-webkit-scrollbar-thumb,.upgrade-page .target-list::-webkit-scrollbar-thumb{background:rgba(245,158,11,0.3);border-radius:2px}.upgrade-page .inventory-item-card,.upgrade-page .target-item-card{background:rgba(14,15,19,0.9);border-radius:8px;padding:.45rem .5rem;display:flex;align-items:center;gap:.5rem;cursor:pointer;border:1px solid rgba(255,255,255,0.04);transition:all .2s ease;position:relative}.upgrade-page .inventory-item-card::before,.upgrade-page .target-item-card::before{content:'';position:absolute;left:0;top:4px;bottom:4px;width:2px;border-radius:1px;background:rgba(255,255,255,0.08);transition:all .2s ease}.upgrade-page .inventory-item-card:hover,.upgrade-page .target-item-card:hover{border-color:rgba(245,158,11,0.2);background:rgba(20,21,26,0.9)}.upgrade-page .inventory-item-card:hover::before,.upgrade-page .target-item-card:hover::before{background:rgba(245,158,11,0.4);width:3px}.upgrade-page .inventory-item-card.selected{border-color:rgba(245,158,11,0.5);background:rgba(245,158,11,0.06);box-shadow:0 0 20px rgba(245,158,11,0.08),inset 0 0 20px rgba(245,158,11,0.03)}.upgrade-page .inventory-item-card.selected::before{background:#f59e0b;width:3px;box-shadow:0 0 8px rgba(245,158,11,0.6)}.upgrade-page .inventory-item-card.disabled,.upgrade-page .target-item-card.disabled{opacity:.35;cursor:not-allowed;pointer-events:none}.upgrade-page .inventory-item-card img,.upgrade-page .target-item-card img{width:42px;height:36px;object-fit:contain;background:rgba(0,0,0,0.3);border-radius:4px;padding:2px;flex-shrink:0}.upgrade-page .inventory-item-name,.upgrade-page .target-item-name{font-size:.72rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#d4d2d8;letter-spacing:.01em}.upgrade-page .inventory-item-meta,.upgrade-page .target-item-meta{display:flex;justify-content:space-between;font-size:.6rem;color:#6b6b7a;margin-top:.1rem}.upgrade-page .inventory-item-price,.upgrade-page .target-item-price{color:#f59e0b;font-weight:700;font-size:.65rem}.upgrade-page .target-search-input{width:100%;padding:.45rem .6rem;background:rgba(0,0,0,0.4);border:1px solid rgba(255,255,255,0.06);border-radius:6px;color:#e0dde8;font-size:.8rem;font-family:'Rajdhani',sans-serif;outline:none;transition:border-color .2s;box-sizing:border-box}.upgrade-page .target-search-input:focus{border-color:rgba(245,158,11,0.4)}.upgrade-page .sort-controls{display:flex;gap:.3rem;margin-top:.4rem}.upgrade-page .sort-btn{flex:1;padding:.25rem .3rem;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.04);border-radius:4px;color:#6b6b7a;font-size:.6rem;font-family:'Rajdhani',sans-serif;font-weight:600;cursor:pointer;transition:all .15s;text-transform:uppercase;letter-spacing:.04em}.upgrade-page .sort-btn:hover{color:#c8c6d0;border-color:rgba(255,255,255,0.1)}.upgrade-page .sort-btn.active{color:#f59e0b;border-color:rgba(245,158,11,0.3);background:rgba(245,158,11,0.08)}.upgrade-page .wheel-panel{background:rgba(20,21,26,0.8);backdrop-filter:blur(8px);border-radius:10px;border:1px solid rgba(255,255,255,0.06);padding:1.25rem 1rem;display:flex;flex-direction:column;align-items:center}.upgrade-page .selected-items-display{display:flex;align-items:center;justify-content:center;gap:.75rem;margin-bottom:.75rem;width:100%}.upgrade-page .selected-item-mini{text-align:center;flex:1;min-width:0}.upgrade-page .selected-item-mini .item-frame{width:64px;height:52px;margin:0 auto;background:rgba(0,0,0,0.4);border-radius:6px;border:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:center;padding:4px;transition:all .3s}.upgrade-page .selected-item-mini.selected .item-frame{border-color:rgba(245,158,11,0.4);box-shadow:0 0 15px rgba(245,158,11,0.1)}.upgrade-page .selected-item-mini img{width:100%;height:100%;object-fit:contain}.upgrade-page .selected-item-mini-name{font-size:.62rem;margin-top:.25rem;color:#8a8a9a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.upgrade-page .selected-item-mini-price{font-size:.7rem;color:#f59e0b;font-weight:700;font-family:'Rajdhani',sans-serif}.upgrade-page .arrow-icon{font-size:1rem;color:#4a4a5a;flex-shrink:0;opacity:.5}.upgrade-page .wheel-container{position:relative;width:280px;height:280px;margin:.25rem 0 .5rem}.upgrade-page .wheel-outer-ring{position:absolute;inset:-8px;border-radius:50%;background:conic-gradient(from 0deg,rgba(245,158,11,0.08),rgba(245,158,11,0.02) 25%,rgba(245,158,11,0.08) 50%,rgba(245,158,11,0.02) 75%,rgba(245,158,11,0.08) 100%);border:1px solid rgba(245,158,11,0.15)}.upgrade-page .wheel{width:100%;height:100%;border-radius:50%;background:conic-gradient(from calc(180deg - var(--probability,0) * 3.6deg / 2),#10b981 0deg,#10b981 calc(var(--probability,0) * 3.6deg),#2d1b1b calc(var(--probability,0) * 3.6deg),#2d1b1b 360deg);border:2px solid rgba(255,255,255,0.08);box-shadow:0 0 40px rgba(0,0,0,0.6),inset 0 0 30px rgba(0,0,0,0.4);position:relative;z-index:1}.upgrade-page .wheel::after{content:'';position:absolute;inset:4px;border-radius:50%;border:1px solid rgba(255,255,255,0.04)}.upgrade-page .wheel-ticks{position:absolute;inset:-4px;border-radius:50%;z-index:2;pointer-events:none}.upgrade-page .wheel-ticks::before{content:'';position:absolute;inset:2px;border-radius:50%;background:repeating-conic-gradient(from 0deg,transparent 0deg,transparent 2deg,rgba(255,255,255,0.06) 2deg,rgba(255,255,255,0.06) 3deg)}.upgrade-page .wheel-pointer-container{position:absolute;top:0;left:0;width:100%;height:100%;z-index:10;pointer-events:none;transform-origin:center center}.upgrade-page .wheel-pointer{position:absolute;top:-14px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:10px solid transparent;border-right:10px solid transparent;border-top:24px solid #f59e0b;filter:drop-shadow(0 0 10px rgba(245,158,11,0.6)) drop-shadow(0 0 20px rgba(245,158,11,0.3))}.upgrade-page .wheel-pointer::after{content:'';position:absolute;top:-28px;left:-4px;width:8px;height:8px;background:#f59e0b;border-radius:50%;box-shadow:0 0 12px rgba(245,158,11,0.8)}.upgrade-page .wheel-center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:52px;height:52px;background:radial-gradient(circle at 40% 35%,#1e1f2a,#0a0b0e);border-radius:50%;border:2px solid rgba(245,158,11,0.3);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:.85rem;color:#f59e0b;z-index:5;font-family:'Russo One',sans-serif;box-shadow:0 0 20px rgba(245,158,11,0.1),inset 0 0 15px rgba(0,0,0,0.5);letter-spacing:.02em}.upgrade-page .upgrade-btn{padding:.65rem 2.5rem;font-family:'Russo One',sans-serif;font-size:.85rem;text-transform:uppercase;letter-spacing:.1em;background:linear-gradient(135deg,#f59e0b,#d97706);color:#0a0b0e;border:none;border-radius:6px;cursor:pointer;transition:all .25s;position:relative;overflow:hidden;margin-top:.25rem}.upgrade-page .upgrade-btn:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 20px rgba(245,158,11,0.3)}.upgrade-page .upgrade-btn:active:not(:disabled){transform:translateY(0)}.upgrade-page .upgrade-btn:disabled{opacity:.3;cursor:not-allowed}.upgrade-page .upgrade-btn::after{content:'';position:absolute;inset:0;background:linear-gradient(135deg,transparent 40%,rgba(255,255,255,0.15) 50%,transparent 60%);transition:transform .4s;transform:translateX(-100%)}.upgrade-page .upgrade-btn:hover:not(:disabled)::after{transform:translateX(100%)}.upgrade-page .result-item-display img{width:72px;height:60px;object-fit:contain;animation:resultItemSlide .5s ease}.upgrade-page .result-item-display span{font-size:.65rem;color:#8a8a9a;text-align:center;max-width:180px}@keyframes resultFadeIn{from{opacity:0}to{opacity:1}}@keyframes resultPulse{0%{transform:scale(0.5);opacity:0}60%{transform:scale(1.15)}100%{transform:scale(1);opacity:1}}@keyframes resultItemSlide{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}.upgrade-page .sparkle-container{position:absolute;inset:0;pointer-events:none;z-index:25;overflow:hidden;border-radius:10px}.upgrade-page .sparkle{position:absolute;width:4px;height:4px;background:#f59e0b;border-radius:50%;animation:sparkleFloat 1.5s ease-out forwards}@keyframes sparkleFloat{0%{transform:translate(0,0) scale(1);opacity:1}100%{transform:translate(var(--dx,50px),var(--dy,-80px)) scale(0);opacity:0}}.upgrade-page .history-section{margin-top:.5rem}.upgrade-page .history-title{font-family:'Russo One',sans-serif;font-size:.85rem;letter-spacing:.06em;text-transform:uppercase;color:#6b6b7a;margin-bottom:.6rem}.upgrade-page .history-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.5rem}.upgrade-page .history-card{background:rgba(20,21,26,0.6);border-radius:8px;padding:.5rem .6rem;display:flex;align-items:center;gap:.5rem;border:1px solid rgba(255,255,255,0.04);transition:border-color .2s}.upgrade-page .history-card:hover{border-color:rgba(255,255,255,0.08)}.upgrade-page .history-card.success{border-left:3px solid rgba(16,185,129,0.5)}.upgrade-page .history-card.fail{border-left:3px solid rgba(239,68,68,0.4)}.upgrade-page .history-card img{width:36px;height:30px;object-fit:contain;background:rgba(0,0,0,0.3);border-radius:4px;padding:2px;flex-shrink:0}.upgrade-page .history-info{flex:1;min-width:0}.upgrade-page .history-names{font-size:.68rem;color:#b0aeb8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.upgrade-page .history-meta{display:flex;justify-content:space-between;font-size:.6rem;color:#6b6b7a;margin-top:.1rem}.upgrade-page .history-result.success{color:#10b981;font-weight:600}.upgrade-page .history-result.fail{color:#ef4444;font-weight:600}.upgrade-page .price-filter-bar{display:flex;gap:.25rem;margin-top:.4rem;flex-wrap:wrap}.upgrade-page .pf-btn{padding:.2rem .45rem;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.04);border-radius:4px;color:#6b6b7a;font-size:.6rem;font-family:'Rajdhani',sans-serif;font-weight:600;cursor:pointer;transition:all .15s;text-transform:uppercase;letter-spacing:.03em}.upgrade-page .pf-btn:hover{color:#c8c6d0;border-color:rgba(255,255,255,0.1)}.upgrade-page .pf-btn.active{color:#f59e0b;border-color:rgba(245,158,11,0.3);background:rgba(245,158,11,0.08)}.upgrade-page .empty-state{text-align:center;padding:1.5rem .5rem;color:#4a4a5a;font-size:.8rem}@media(max-width:1000px){.upgrade-page .upgrade-layout{grid-template-columns:1fr;gap:.75rem}.upgrade-page .inventory-panel,.upgrade-page .target-panel{max-height:320px}.upgrade-page .wheel-container{width:240px;height:240px}}@media(max-width:768px){.upgrade-page .upgrade-header h1{font-size:1.3rem}.upgrade-page .wheel-container{width:200px;height:200px}.upgrade-page .wheel-center{width:44px;height:44px;font-size:.7rem}.upgrade-page .selected-item-mini .item-frame{width:52px;height:42px}.upgrade-page .history-grid{grid-template-columns:1fr}}.scroll-item-sm.target-item{transition:all .3s ease;z-index:100}.mini-scroll-item-sm.target-mini-item{transition:all .3s ease;z-index:100}.scroll-item-sm.target-item[style*="border-color"]{box-shadow:0 0 30px currentColor}.mini-scroll-item-sm.target-mini-item[style*="border-color"]{box-shadow:0 0 20px currentColor}.multi-scroll-container{display:grid;gap:1.5rem;margin:2rem 0;justify-content:center}.scroll-wrapper{text-align:center}.mini-scroll-container{width:100%;min-width:180px;height:200px;position:relative;overflow:hidden;background:linear-gradient(135deg,#1a1a2e 0,#16213e 100%);border-radius:8px;border:2px solid var(--border-color)}.mini-scroll-track{display:flex;position:absolute;top:0;left:0;height:100%;will-change:transform;transition:filter .3s}.mini-scroll-item{width:90px;height:180px;margin:10px 10px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;background:var(--bg-card);border-radius:6px;padding:8px 4px;border:2px solid transparent}.mini-scroll-item img{width:70px;height:70px;object-fit:contain}.mini-item-name{font-size:9px;text-align:center;margin-top:5px;word-break:break-word}.mini-item-rarity{font-size:8px;margin-top:3px;font-weight:bold}.mini-center-indicator{position:absolute;left:50%;top:0;bottom:0;width:3px;background:linear-gradient(to bottom,transparent,#ffd700,#ffd700,transparent);transform:translateX(-50%);z-index:10;box-shadow:0 0 20px #ffd700;pointer-events:none}.result-count{position:absolute;top:5px;right:5px;background:var(--primary-color);color:white;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:bold}.result-item{position:relative}.results-actions{display:flex;gap:1rem;justify-content:center;margin-top:2rem}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.15)}.glass-card,.card,[class*="panel"]{background:rgba(20,21,26,0.7);backdrop-filter:blur(12px);border:1px solid var(--border-color);border-radius:12px;transition:border-color .2s,box-shadow .2s}.glass-card:hover,.card:hover,[class*="panel"]:hover{border-color:rgba(255,255,255,0.1)}.modal,[class*="modal"],[class*="Modal"]{background:rgba(10,11,14,0.85);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px)}a,button,.btn,.nav-link,.item-card,[class*="btn"]{transition:all .2s ease}.btn{position:relative;overflow:hidden;border-radius:8px;font-weight:600;letter-spacing:.02em;cursor:pointer;border:none;display:inline-flex;align-items:center;justify-content:center;gap:.4rem}.btn:active{transform:scale(0.97)}.btn-primary{background:linear-gradient(135deg,var(--primary-color),#f97316);color:white}.btn-primary:hover{filter:brightness(1.1);box-shadow:0 4px 20px rgba(245,158,11,0.3)}main,.container,.crash-layout,.inventory-container{animation:contentFadeIn .3s ease}@keyframes contentFadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}main{padding:1.5rem 1rem}main.container{padding:1.5rem 20px}p,span,div,label{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}input,select,textarea{background:rgba(255,255,255,0.04);border:1px solid var(--border-color);border-radius:8px;color:var(--text-primary);padding:.6rem .8rem;font-family:'Rajdhani',sans-serif;font-size:.95rem;transition:border-color .2s,box-shadow .2s;outline:none}input:focus,select:focus,textarea:focus{border-color:var(--primary-color);box-shadow:0 0 0 2px rgba(245,158,11,0.1)}input::placeholder{color:var(--text-secondary);opacity:.6}a:not(.btn):not(.nav-link):not(.logo){color:var(--primary-color);text-decoration:none;border-bottom:1px solid transparent;transition:border-color .2s}a:not(.btn):not(.nav-link):not(.logo):hover{border-bottom-color:var(--primary-color)}.toast-container{position:fixed;top:72px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:380px;width:100%}.toast{background:var(--bg-elevated);border:1px solid var(--border-light);border-radius:10px;padding:12px 16px;display:flex;align-items:flex-start;gap:10px;pointer-events:auto;box-shadow:0 8px 32px rgba(0,0,0,0.5);animation:toastSlideIn .3s cubic-bezier(0.16,1,0.3,1) forwards;transform-origin:top right;position:relative;overflow:hidden}.toast::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px}.toast-success::before{background:var(--success)}.toast-error::before{background:var(--danger)}.toast-info::before{background:var(--primary)}.toast-warning::before{background:var(--warning)}.toast-icon{width:24px;height:24px;display:flex;align-items:center;justify-content:center;flex-shrink:0;border-radius:6px;font-size:.85rem}.toast-success .toast-icon{background:rgba(16,185,129,0.15)}.toast-error .toast-icon{background:rgba(239,68,68,0.15)}.toast-info .toast-icon{background:rgba(245,158,11,0.15)}.toast-warning .toast-icon{background:rgba(245,158,11,0.15)}.toast-content{flex:1;min-width:0}.toast-title{font-size:.82rem;font-weight:600;color:var(--text);line-height:1.3}.toast-message{font-size:.75rem;color:var(--text-dim);margin-top:2px;line-height:1.3}.toast-close{width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:.75rem;flex-shrink:0;border-radius:4px;transition:all .15s}.toast-close:hover{background:rgba(255,255,255,0.06);color:var(--text)}.toast-removing{animation:toastSlideOut .25s cubic-bezier(0.4,0,1,1) forwards}@keyframes toastSlideIn{from{opacity:0;transform:translateX(100%) scale(0.9)}to{opacity:1;transform:translateX(0) scale(1)}}@keyframes toastSlideOut{from{opacity:1;transform:translateX(0) scale(1)}to{opacity:0;transform:translateX(100%) scale(0.9)}}.confirm-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:10000;animation:confirmFadeIn .2s ease}.confirm-dialog{background:var(--bg-card);border:1px solid var(--border-light);border-radius:14px;padding:24px;max-width:400px;width:90%;animation:confirmScaleIn .25s cubic-bezier(0.16,1,0.3,1);box-shadow:0 24px 80px rgba(0,0,0,0.6)}.confirm-dialog-header{display:flex;align-items:center;gap:10px;margin-bottom:12px}.confirm-dialog-icon{width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:10px;font-size:1.1rem;flex-shrink:0}.confirm-dialog-icon-warning{background:rgba(239,68,68,0.15)}.confirm-dialog-icon-info{background:rgba(245,158,11,0.15)}.confirm-dialog-title{font-size:1rem;font-weight:600;color:var(--text)}.confirm-dialog-message{font-size:.88rem;color:var(--text-dim);line-height:1.5;margin-bottom:20px;padding-left:46px}.confirm-dialog-actions{display:flex;gap:10px;justify-content:flex-end}.confirm-dialog-actions .btn{min-width:90px;padding:.55rem 1.2rem;font-size:.8rem}.confirm-dialog .btn-danger{background:var(--danger);color:white;border:none}.confirm-dialog .btn-danger:hover{background:#dc2626;box-shadow:0 4px 16px rgba(239,68,68,0.3)}@keyframes confirmFadeIn{from{opacity:0}to{opacity:1}}@keyframes confirmScaleIn{from{opacity:0;transform:scale(0.9) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}} \ No newline at end of file diff --git a/static/js/app.min.js b/static/js/app.min.js new file mode 100644 index 0000000..6df792f --- /dev/null +++ b/static/js/app.min.js @@ -0,0 +1,14 @@ +async function logout(){try{await fetch('/web/api/auth/logout',{method:'POST'});}catch(e){} +window.location.href='/';} +async function refreshBalance(){try{const res=await fetch('/web/api/user/balance');const data=await res.json();if(data.success){const el=document.getElementById('siteBalanceDisplay');if(el){el.innerHTML=Math.floor(data.balance).toLocaleString('ru')+' ₽ ▼ депозит';}}}catch(e){}} +function getRarityColor(rarity){const colors={'Consumer Grade':'#b0b0b0','Industrial Grade':'#5e98d9','Mil-Spec':'#4b69ff','Mil-Spec Grade':'#4b69ff','Restricted':'#8847ff','Classified':'#d32ce6','Covert':'#eb4b4b','Rare Special Item':'#ffd700','Extraordinary':'#ffd700',};return colors[rarity]||'#b0b0b0';} +function formatNumber(n){return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g,' ');} +function debounce(fn,ms=300){let timer;return function(...args){clearTimeout(timer);timer=setTimeout(()=>fn.apply(this,args),ms);};} +function escapeHtml(str){const div=document.createElement('div');div.textContent=str;return div.innerHTML;} +function showNotification(message,type='info',duration=3000){const existing=document.querySelector('.notification-toast');if(existing)existing.remove();const colors={success:'#22c55e',error:'#ef4444',info:'#3b82f6',warning:'#f59e0b',};const toast=document.createElement('div');toast.className='notification-toast';toast.style.cssText=`position:fixed;top:20px;right:20px;z-index:99999;background:#1a1a2e;border:1px solid ${colors[type]||colors.info};color:white;padding:12px 20px;border-radius:8px;font-size:14px;box-shadow:0 10px 40px rgba(0,0,0,0.5);animation:slideInRight 0.3s ease;max-width:400px;`;toast.textContent=message;document.body.appendChild(toast);setTimeout(()=>{toast.style.animation='slideOutRight 0.3s ease forwards';setTimeout(()=>toast.remove(),300);},duration);} +if(!document.getElementById('notificationStyles')){const style=document.createElement('style');style.id='notificationStyles';style.textContent=`@keyframes slideInRight{from{transform:translateX(120%);opacity:0;} +to{transform:translateX(0);opacity:1;}}@keyframes slideOutRight{from{transform:translateX(0);opacity:1;} +to{transform:translateX(120%);opacity:0;}}`;document.head.appendChild(style);} +function showAchievementPopup(title,reward){let popup=document.getElementById('achievementGlobalPopup');if(!popup){popup=document.createElement('div');popup.id='achievementGlobalPopup';popup.style.cssText=`position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:99999;background:linear-gradient(135deg,#1a3a1a,#0d260d);border:1px solid#22c55e;border-radius:12px;padding:1rem 1.5rem;box-shadow:0 10px 40px rgba(0,0,0,0.5);animation:slideInDown 0.5s ease;max-width:400px;width:90%;text-align:center;`;popup.innerHTML=`
🏆 Новое достижение!
`;document.body.appendChild(popup);if(!document.getElementById('slideInDownStyle')){const s=document.createElement('style');s.id='slideInDownStyle';s.textContent=`@keyframes slideInDown{from{transform:translateX(-50%)translateY(-120%);opacity:0;} +to{transform:translateX(-50%)translateY(0);opacity:1;}}`;document.head.appendChild(s);}} +document.getElementById('globalPopupTitle').textContent=title;document.getElementById('globalPopupReward').textContent='';popup.style.display='block';if(window.SoundManager)SoundManager.achievement();setTimeout(()=>{popup.style.animation='slideInDown 0.5s ease reverse forwards';setTimeout(()=>{popup.style.display='none';popup.style.animation='slideInDown 0.5s ease';},500);},4000);} \ No newline at end of file diff --git a/static/js/notifications.min.js b/static/js/notifications.min.js new file mode 100644 index 0000000..1deb8ea --- /dev/null +++ b/static/js/notifications.min.js @@ -0,0 +1,19 @@ +(function(){'use strict';let toastId=0;const container=document.createElement('div');container.className='toast-container';container.id='toastContainer';document.body.appendChild(container);function escapeHtml(text){const d=document.createElement('div');d.textContent=text||'';return d.innerHTML;} +window.Notify={toast:function(message,type,title){if(!message)return;type=type||'info';const icons={success:'✓',error:'✕',info:'ℹ',warning:'⚠'};const titles={success:title||'Успешно',error:title||'Ошибка',info:title||'Информация',warning:title||'Внимание'};const icon=icons[type]||'ℹ';const t=titles[type];const id=++toastId;const el=document.createElement('div');el.className='toast toast-'+type;el.id='toast-'+id;el.innerHTML='
'+icon+'
'+ +'
'+ +'
'+escapeHtml(t)+'
'+ +'
'+escapeHtml(message)+'
'+ +'
'+ +'';container.appendChild(el);const timeout=setTimeout(function(){Notify.dismiss(id);},4000);el._timeout=timeout;el._id=id;return id;},success:function(message){return this.toast(message,'success');},error:function(message){return this.toast(message,'error');},info:function(message){return this.toast(message,'info');},warning:function(message){return this.toast(message,'warning');},dismiss:function(id){var el=document.getElementById('toast-'+id);if(!el)return;if(el._timeout)clearTimeout(el._timeout);if(el.classList.contains('toast-removing'))return;el.classList.add('toast-removing');setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},250);},confirm:function(message,title,callback){if(typeof title==='function'){callback=title;title='Подтверждение';} +title=title||'Подтверждение';var overlay=document.createElement('div');overlay.className='confirm-overlay';overlay.innerHTML='
'+ +'
'+ +'
'+ +'
'+escapeHtml(title)+'
'+ +'
'+ +'
'+escapeHtml(message)+'
'+ +'
'+ +''+ +''+ +'
'+ +'
';document.body.appendChild(overlay);var result=false;function close(){if(overlay.parentNode)overlay.parentNode.removeChild(overlay);if(callback)callback(result);} +overlay.querySelector('.confirm-cancel').addEventListener('click',function(){result=false;close();});overlay.querySelector('.confirm-ok').addEventListener('click',function(){result=true;close();});overlay.addEventListener('click',function(e){if(e.target===overlay){result=false;close();}});document.addEventListener('keydown',function handler(e){if(e.key==='Escape'){result=false;close();document.removeEventListener('keydown',handler);}});return overlay;}};})(); \ No newline at end of file diff --git a/static/js/sounds.min.js b/static/js/sounds.min.js new file mode 100644 index 0000000..7a13112 --- /dev/null +++ b/static/js/sounds.min.js @@ -0,0 +1,4 @@ +const SoundManager={_ctx:null,_enabled:true,_volume:0.3,_activeNodes:new Set(),_timeouts:[],init(){try{this._ctx=new(window.AudioContext||window.webkitAudioContext)();}catch(e){console.warn('Web Audio API not supported');this._enabled=false;} +const saved=localStorage.getItem('sound_enabled');if(saved!==null)this._enabled=saved==='true';},setEnabled(on){this._enabled=on;localStorage.setItem('sound_enabled',on);},toggle(){this.setEnabled(!this._enabled);return this._enabled;},stopAll(){this._timeouts.forEach(clearTimeout);this._timeouts=[];this._activeNodes.forEach(node=>{try{node.stop();}catch(e){} +try{node.disconnect();}catch(e){}});this._activeNodes.clear();},_trackNode(node,duration){this._activeNodes.add(node);node.addEventListener('ended',()=>this._activeNodes.delete(node));setTimeout(()=>{this._activeNodes.delete(node);try{node.disconnect();}catch(e){}},(duration+0.1)*1000);},_play(freq,duration,type='sine',volume=1){if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();const osc=this._ctx.createOscillator();const gain=this._ctx.createGain();osc.type=type;osc.frequency.setValueAtTime(freq,this._ctx.currentTime);gain.gain.setValueAtTime(this._volume*volume,this._ctx.currentTime);gain.gain.exponentialRampToValueAtTime(0.001,this._ctx.currentTime+duration);osc.connect(gain);gain.connect(this._ctx.destination);osc.start();osc.stop(this._ctx.currentTime+duration);this._trackNode(osc,duration);this._trackNode(gain,duration);},_noise(duration,volume=1){if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();const bufferSize=this._ctx.sampleRate*duration;const buffer=this._ctx.createBuffer(1,bufferSize,this._ctx.sampleRate);const data=buffer.getChannelData(0);for(let i=0;i{this._timeouts=this._timeouts.filter(t=>t!==id);fn();},ms);this._timeouts.push(id);return id;},caseOpen(){this._play(800,0.1,'square',0.3);this._delay(()=>this._play(400,0.15,'sawtooth',0.2),80);this._delay(()=>this._play(200,0.2,'sine',0.15),180);this._noise(0.05,0.4);},caseRare(){this._play(523,0.15,'sine',0.3);this._delay(()=>this._play(659,0.15,'sine',0.3),100);this._delay(()=>this._play(784,0.15,'sine',0.3),200);this._delay(()=>this._play(1047,0.3,'sine',0.4),300);},caseCovert(){this._play(392,0.2,'sawtooth',0.3);this._delay(()=>this._play(523,0.2,'sawtooth',0.3),150);this._delay(()=>this._play(659,0.2,'sawtooth',0.3),300);this._delay(()=>this._play(784,0.4,'sine',0.5),450);this._delay(()=>this._play(1047,0.6,'sine',0.6),600);},slotSpin(){for(let i=0;i<8;i++){this._delay(()=>{this._play(300+Math.random()*400,0.05,'square',0.15);},i*80);}},slotMatch(){this._play(523,0.1,'sine',0.3);this._delay(()=>this._play(659,0.1,'sine',0.3),100);this._delay(()=>this._play(784,0.1,'sine',0.3),200);this._delay(()=>this._play(1047,0.4,'sine',0.5),300);},contractSubmit(){this._play(150,0.3,'sawtooth',0.2);this._delay(()=>this._play(200,0.2,'square',0.2),200);this._delay(()=>this._play(250,0.1,'square',0.15),350);},contractResult(){this._play(400,0.15,'sine',0.3);this._delay(()=>this._play(600,0.15,'sine',0.3),120);this._delay(()=>this._play(800,0.3,'sine',0.4),240);},wheelSpin(){this._play(600+Math.random()*400,0.04,'triangle',0.12);this._play(200,0.02,'square',0.06);},wheelWin(){const t=this._ctx.currentTime;if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();[523,659,784,1047].forEach((freq,i)=>{const osc=this._ctx.createOscillator();const gain=this._ctx.createGain();osc.type='sine';osc.frequency.setValueAtTime(freq,t+i*0.1);gain.gain.setValueAtTime(0,t+i*0.1);gain.gain.linearRampToValueAtTime(this._volume*0.35,t+i*0.1+0.05);gain.gain.exponentialRampToValueAtTime(0.001,t+i*0.1+0.5);osc.connect(gain);gain.connect(this._ctx.destination);osc.start(t+i*0.1);osc.stop(t+i*0.1+0.5);this._trackNode(osc,0.6);this._trackNode(gain,0.6);});this._delay(()=>this._play(1568,0.4,'sine',0.25),350);this._delay(()=>this._play(2093,0.6,'sine',0.2),500);},wheelLose(){this._play(300,0.15,'sawtooth',0.25);this._delay(()=>this._play(200,0.2,'sawtooth',0.2),100);this._delay(()=>this._play(120,0.3,'sawtooth',0.15),220);this._noise(0.15,0.3);this._delay(()=>{this._play(60,0.5,'sine',0.2);this._play(45,0.6,'sine',0.15);},350);},achievement(){this._play(659,0.1,'sine',0.3);this._delay(()=>this._play(523,0.1,'sine',0.3),100);this._delay(()=>this._play(784,0.1,'sine',0.3),200);this._delay(()=>this._play(659,0.1,'sine',0.3),300);this._delay(()=>this._play(1047,0.4,'sine',0.5),400);},crashBet(){this._play(300,0.1,'square',0.15);},crashTick(){this._play(500+Math.random()*500,0.03,'sine',0.08);},crashCrashed(){this._noise(0.3,0.6);this._play(80,0.5,'sawtooth',0.4);this._delay(()=>this._play(50,0.8,'sine',0.3),100);},crashCashout(){this._play(600,0.1,'sine',0.3);this._delay(()=>this._play(800,0.1,'sine',0.3),80);this._delay(()=>this._play(1000,0.2,'sine',0.4),160);},click(){this._play(800,0.03,'sine',0.1);},error(){this._play(200,0.15,'square',0.2);},balanceUpdate(){this._play(1000,0.05,'sine',0.1);}};document.addEventListener('DOMContentLoaded',()=>SoundManager.init()); \ No newline at end of file diff --git a/static/js/websocket.min.js b/static/js/websocket.min.js new file mode 100644 index 0000000..3872378 --- /dev/null +++ b/static/js/websocket.min.js @@ -0,0 +1,4 @@ +const WS={_ws:null,_reconnectTimer:null,_listeners:{},_connected:false,connect(){if(this._ws&&this._ws.readyState===WebSocket.OPEN)return;const protocol=window.location.protocol==='https:'?'wss:':'ws:';const url=`${protocol} +try{this._ws=new WebSocket(url);}catch(e){console.warn('WS connection failed, retrying in 5s');this._scheduleReconnect();return;} +this._ws.onopen=()=>{this._connected=true;this._emit('connected');};this._ws.onclose=()=>{this._connected=false;this._emit('disconnected');this._scheduleReconnect();};this._ws.onerror=()=>{this._emit('error');};this._ws.onmessage=(event)=>{try{const data=JSON.parse(event.data);this._emit(data.type,data);}catch(e){}};this._pingInterval=setInterval(()=>{if(this._ws&&this._ws.readyState===WebSocket.OPEN){this._ws.send(JSON.stringify({type:'ping'}));}},30000);},disconnect(){if(this._pingInterval)clearInterval(this._pingInterval);if(this._reconnectTimer)clearTimeout(this._reconnectTimer);if(this._ws){this._ws.onclose=null;this._ws.close();this._ws=null;} +this._connected=false;},_scheduleReconnect(){if(this._reconnectTimer)return;this._reconnectTimer=setTimeout(()=>{this._reconnectTimer=null;this.connect();},5000);},on(event,callback){if(!this._listeners[event])this._listeners[event]=[];this._listeners[event].push(callback);return()=>{this._listeners[event]=this._listeners[event].filter(cb=>cb!==callback);};},_emit(event,data){(this._listeners[event]||[]).forEach(cb=>cb(data));(this._listeners['*']||[]).forEach(cb=>cb(event,data));},isConnected(){return this._connected;}};document.addEventListener('DOMContentLoaded',()=>WS.connect()); \ No newline at end of file diff --git a/templates/achievements.html b/templates/achievements.html index 530a2ba..b9746aa 100644 --- a/templates/achievements.html +++ b/templates/achievements.html @@ -4,7 +4,7 @@ Достижения - CS2 Simulator - +