From 47c7f17c9036e2cf703c62da4aae82834c6e9ead Mon Sep 17 00:00:00 2001 From: sasheg Date: Sat, 25 Jul 2026 13:11:08 +0000 Subject: [PATCH] =?UTF-8?q?RPU=20v2,=20promo/bank=20=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D0=B0,=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0,=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit — RPU v2: бюджет удачи, потолок (ceiling), комбек после проигрышей, hot/cold анализ, интеграция с promo-фазами — Bank API: mock-банк для карты (депозит/вывод с балансом 1000₽) — Promo-сервис: автогенерация промокодов каждый час, типы (card_to_site, luck_boost, ceiling_boost, free_case, reset_streak) — Promo-фазы: LUCKY/NEUTRAL/UNLUCKY, переключение каждые 1-6ч — Admin: история транзакций (user_history), управление промокодами, управление promo-фазами, API фильтров/коллекций, RPU info — Database: WAL mode, новые поля RPU v2, TransactionLog, карточный баланс, статистики (streaks, ceiling, budget) — Frontend: /deposit, /promo/activate, /withdraw, /card-balance, /online-count, /rpu-info, lifespan вместо startup event — UI: user_history.html, улучшения профиля, кейсов, навигации — Cases: обновление cases.json (1892 строки новых данных) — Achievements: кэш коллекций, оптимизация is_custom проверок — Sounds: дополнительные звуковые эффекты — Upgrade: интеграция с RPU (множители, streaks) — .gitignore: исключены .db, .bak, __pycache__ --- .gitignore | 7 +- achievements.py | 387 +++--- admin.py | 481 +++++++- bank_api.py | 107 ++ cases.json | 1892 ++++++++++++++++++++++++++++- database.py | 204 +++- frontend.py | 1196 ++++++++++-------- mainpage.json | 12 +- promo_phase.json | 5 + promo_phase.py | 178 +++ promo_service.py | 352 ++++++ rpu.py | 658 ++++++++-- static/js/app.js | 9 +- static/js/sounds.js | 137 ++- templates/_activity_sidebar.html | 87 +- templates/achievements.html | 5 +- templates/activity.html | 214 ---- templates/admin/base.html | 162 ++- templates/admin/cases.html | 365 ++++-- templates/admin/dashboard.html | 27 + templates/admin/user_detail.html | 62 +- templates/admin/user_history.html | 122 ++ templates/admin/users.html | 38 +- templates/case_detail.html | 39 +- templates/cases.html | 147 +++ templates/contracts.html | 2 +- templates/crash.html | 14 +- templates/profile.html | 172 ++- templates/upgrade.html | 22 +- upgrade.py | 54 +- 30 files changed, 5862 insertions(+), 1295 deletions(-) create mode 100644 bank_api.py create mode 100644 promo_phase.json create mode 100644 promo_phase.py create mode 100644 promo_service.py delete mode 100644 templates/activity.html create mode 100644 templates/admin/user_history.html diff --git a/.gitignore b/.gitignore index 04ebc30..461e8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ +*.db +*.db-shm +*.db-wal +*.bak __pycache__/ *.pyc -*.pyo .DS_Store -cs2_simulator.db -*.log diff --git a/achievements.py b/achievements.py index 76fc13b..401259e 100644 --- a/achievements.py +++ b/achievements.py @@ -5,6 +5,43 @@ 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 + from backend import ALL_ITEMS, COLLECTIONS_INDEX, extract_base_name + coll_base_names = {} + coll_item_ids = {} + for coll_name, item_ids in COLLECTIONS_INDEX.items(): + base_set = set() + clean_ids = [cid for cid in item_ids if cid < len(ALL_ITEMS)] + for cid in clean_ids: + item = ALL_ITEMS[cid] + base = extract_base_name(item.get("market_hash_name", "")) + if base: + base_set.add(base) + 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 + for cid in item_ids: + if cid < len(ALL_ITEMS) and ALL_ITEMS[cid].get("price_source") == "custom": + 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, + } + # ─── Определения достижений ───────────────────────────────────────────────── ACHIEVEMENT_DEFS = [ @@ -1803,126 +1840,92 @@ ACHIEVEMENT_DEFS = [ # ─── Функции проверки достижений ────────────────────────────────────────── def check_collection_achievements(db: Session, user_id: int): - """Проверяет достижения, связанные с коллекциями""" - from backend import ALL_ITEMS, COLLECTIONS_INDEX, extract_base_name + """Проверяет достижения, связанные с коллекциями — использует кэш.""" from database import InventoryItem + _build_collection_cache() + cache = _COLLECTION_CACHE + coll_base_names = cache["base_names"] + coll_item_ids = cache["item_ids"] + coll_is_custom = cache["is_custom"] results = [] - # Все предметы пользователя - user_inv = db.query(InventoryItem.item_id).filter( + # ID предметов пользователя + user_ids = {iid for (iid,) in db.query(InventoryItem.item_id).filter( InventoryItem.user_id == user_id - ).all() - user_ids = {iid for (iid,) in user_inv} + ).all()} - # Собираем все коллекции, в которых есть предметы пользователя - # и уникальные базовые имена предметов из коллекций - user_collections = set() - user_coll_base_names = {} # collection_name → set of base names owned - all_coll_base_names = {} # collection_name → set of all base names in collection - - for coll_name, item_ids in COLLECTIONS_INDEX.items(): - base_set = set() - for cid in item_ids: - item = ALL_ITEMS[cid] if cid < len(ALL_ITEMS) else None - if item: - base = extract_base_name(item.get("market_hash_name", "")) - if base: - base_set.add(base) - if base_set: - all_coll_base_names[coll_name] = base_set - - # Check if user owns any item from this collection - owned_bases = set() - for cid in item_ids: - if cid in user_ids: - item = ALL_ITEMS[cid] if cid < len(ALL_ITEMS) else None - if item: - base = extract_base_name(item.get("market_hash_name", "")) + # Коллекции пользователя + 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(): + owned = user_ids & item_ids_set + if owned: + owned_bases = set() + for cid in owned: + if cid < len(ALL_ITEMS): + base = extract_base_name(ALL_ITEMS[cid].get("market_hash_name", "")) if base: owned_bases.add(base) - if owned_bases: - user_collections.add(coll_name) - user_coll_base_names[coll_name] = owned_bases + if owned_bases: + user_coll_base_names[coll_name] = owned_bases - # collection_count: unique collections user has items from - coll_count = len(user_collections) + 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) if r: results.append(r) - # collection_items: unique base items from collections owned all_owned_bases = set() for bases in user_coll_base_names.values(): all_owned_bases.update(bases) - total_unique_items = len(all_owned_bases) + 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_items) + r = check_achievement(db, user_id, n, total_unique) if r: results.append(r) - # collection_complete_count: collections where user owns ALL base items complete_count = 0 - for coll_name, all_bases in all_coll_base_names.items(): - owned_bases = user_coll_base_names.get(coll_name, set()) - if all_bases.issubset(owned_bases): + for coll_name, all_bases in coll_base_names.items(): + 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) if r: results.append(r) - # coll_all_base: all NON-CUSTOM collections complete - non_custom_collections = {} - for coll_name, item_ids in COLLECTIONS_INDEX.items(): - # Check if any item in this collection is custom - is_custom = False - for cid in item_ids: - item = ALL_ITEMS[cid] if cid < len(ALL_ITEMS) else None - if item and item.get("price_source") == "custom": - is_custom = True - break - if not is_custom and coll_name in all_coll_base_names: - non_custom_collections[coll_name] = all_coll_base_names[coll_name] - - all_base_complete = True - for coll_name, all_bases in non_custom_collections.items(): - owned_bases = user_coll_base_names.get(coll_name, set()) - if not all_bases.issubset(owned_bases): - all_base_complete = False - break - if all_base_complete and non_custom_collections: + 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 + ) + if all_base_ok and non_custom: r = check_achievement(db, user_id, "coll_all_base", 1) if r: results.append(r) - # coll_all: ALL collections complete (including custom) - all_complete = True - for coll_name, all_bases in all_coll_base_names.items(): - owned_bases = user_coll_base_names.get(coll_name, set()) - if not all_bases.issubset(owned_bases): - all_complete = False - break - if all_complete and all_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) if r: results.append(r) - # coll_rarity_complete: complete all items of specific rarity tiers in any collection - from backend import RARITY_ORDER rarity_collectors = [ - ("coll_cheap_collector", {0}), # Consumer Grade - ("coll_mid_collector", {2}), # Mil-Spec / Mil-Spec Grade - ("coll_rare_collector", {3, 4, 5, 6, 7, 8}), # Restricted+ - ("coll_covert_collector", {5}), # Covert + ("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: found = False - for coll_name in all_coll_base_names: - # Collect all items at target rarity levels for this collection + for coll_name in coll_base_names: + owned = user_coll_base_names.get(coll_name, set()) all_target = set() owned_target = set() for cid in COLLECTIONS_INDEX.get(coll_name, []): - item = ALL_ITEMS[cid] if cid < len(ALL_ITEMS) else None - if not item: + if cid >= len(ALL_ITEMS): continue + item = ALL_ITEMS[cid] base = extract_base_name(item.get("market_hash_name", "")) if not base: continue @@ -1938,24 +1941,13 @@ def check_collection_achievements(db: Session, user_id: int): r = check_achievement(db, user_id, ach_name, 1) if r: results.append(r) - # coll_case_complete: complete all items from a case - for coll_name, all_bases in all_coll_base_names.items(): - if not coll_name.startswith("Case:"): - continue - owned_bases = user_coll_base_names.get(coll_name, set()) - if all_bases.issubset(owned_bases): - r = check_achievement(db, user_id, "coll_full_case", 1) - if r: results.append(r) - break - # Also check non-case named collections - for coll_name, all_bases in all_coll_base_names.items(): + for coll_name in coll_base_names: if coll_name.startswith("Case:"): - continue - owned_bases = user_coll_base_names.get(coll_name, set()) - if all_bases.issubset(owned_bases): - # This also counts for coll_full_case if user wants case completion - pass # Already counted in collection_complete_count above - + 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) + if r: results.append(r) + break return results @@ -2009,15 +2001,10 @@ def check_achievement(db: Session, user_id: int, achievement_name: str, current_ if current_value >= ach.requirement_value: ua.unlocked_at = datetime.utcnow() - from database import User - user = db.query(User).filter(User.id == user_id).first() - if user: - user.balance += ach.reward_amount - db.commit() activity = ActivityFeed( user_id=user_id, - username=user.username if user else "unknown", + username=f"user_{user_id}", activity_type="achievement", message=f"🏆 Получено достижение: {ach.title}", data_json=json.dumps({ @@ -2027,7 +2014,7 @@ def check_achievement(db: Session, user_id: int, achievement_name: str, current_ }) ) db.add(activity) - db.commit() + db.flush() return { "name": ach.name, @@ -2036,10 +2023,11 @@ def check_achievement(db: Session, user_id: int, achievement_name: str, current_ "reward": ach.reward_amount } - db.commit() + db.flush() return None + # ─── Существующие проверки (расширенные) ─────────────────────────────────── def check_open_cases_achievements(db: Session, user_id: int): @@ -2110,31 +2098,19 @@ def check_upgrade_risk_achievements(db: Session, user_id: int, item_price: float 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 = [] - # Consecutive wins/losses - all_upgrades = db.query(Upgrade.success).filter( - Upgrade.user_id == user_id - ).order_by(Upgrade.id.desc()).all() + # Используем UpgradeRPU.current_win_streak / current_lose_streak вместо загрузки всех апгрейдов + rpu = get_upgrade_rpu(db, user_id) - streak = 0 - streak_type = None # True = wins, False = losses - for (s,) in all_upgrades: - if streak_type is None: - streak_type = s - streak = 1 - elif s == streak_type: - streak += 1 - else: - break - - if streak_type == True: + 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, streak) + r = check_achievement(db, user_id, n, rpu.current_win_streak) if r: results.append(r) - elif streak_type == False: + if rpu.current_lose_streak > 0: for n in ["upgrade_consecutive_losses_5", "upgrade_consecutive_losses_10"]: - r = check_achievement(db, user_id, n, streak) + r = check_achievement(db, user_id, n, rpu.current_lose_streak) if r: results.append(r) # Low value win @@ -2287,108 +2263,115 @@ def check_inventory_achievements(db: Session, user_id: int): 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-связанные достижения""" + """Проверяет все Dildo-связанные достижения — инвентарь грузит только IN-запросом.""" from database import InventoryItem from backend import ALL_ITEMS + from sqlalchemy import func as sa_func results = [] - items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all() - dildo_items = [] - for inv_item in items: - if inv_item.item_id < len(ALL_ITEMS): - item_data = ALL_ITEMS[inv_item.item_id] - if item_data.get("weapon") == "Dildo": - dildo_items.append((inv_item, item_data)) + # Загружаем только 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_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 item price in inventory - best_price = 0 - for inv_item in items: - if inv_item.item_id < len(ALL_ITEMS): - p = ALL_ITEMS[inv_item.item_id].get("price_rub", 0) - if p > best_price: - best_price = p + # 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 inventory value - total_val = 0 - for inv_item in items: - if inv_item.item_id < len(ALL_ITEMS): - total_val += ALL_ITEMS[inv_item.item_id].get("price_rub", 0) + 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) - # Dildo float achievements - best_float = 1.0 - for inv_item, item_data in dildo_items: - f = inv_item.float_value or 1.0 - if f < best_float: - best_float = f - if best_float < 0.01: + # 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_float < 0.005: + 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 = 1.0 - for inv_item in items: - f = inv_item.float_value or 1.0 - if f < ultra_rare: - ultra_rare = f + 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: exact float 0.001 (or close) - for inv_item in items: - f = inv_item.float_value or 0 - if abs(f - 0.001) < 0.0001: - r = check_achievement(db, user_id, "secret_exact_float", 1) - if r: results.append(r) - break - - # Dildo all rarities - dildo_rarities = set() - for inv_item, item_data in dildo_items: - dildo_rarities.add(item_data.get("rarity")) - if len(dildo_rarities) >= 5: # Consumer, Industrial, Mil-Spec, Restricted, Classified, Covert - r = check_achievement(db, user_id, "dildo_all_rarities", 1) + # 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 unique count (by pattern/name) - unique_patterns = set() - for inv_item, item_data in dildo_items: - name = item_data.get("market_hash_name", "") - unique_patterns.add(name) - r = check_achievement(db, user_id, "dildo_full_set", len(unique_patterns)) - 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) - # Dildo total value - dildo_total = 0 - for inv_item, item_data in dildo_items: - dildo_total += item_data.get("price_rub", 0) - r = check_achievement(db, user_id, "dildo_worth_100m", dildo_total) - if r: results.append(r) - - # Secret: specific dildo name - for inv_item, item_data in dildo_items: - name = item_data.get("market_hash_name", "") - if "Big Black" in name: - r = check_achievement(db, user_id, "secret_dildo_specific", 1) + if len(dildo_rarities) >= 5: + r = check_achievement(db, user_id, "dildo_all_rarities", 1) if r: results.append(r) - break + + 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 @@ -2480,14 +2463,10 @@ def check_achievements_count_achievements(db: Session, user_id: int): def check_rare_item_count_achievements(db: Session, user_id: int): from database import InventoryItem - from backend import ALL_ITEMS - items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all() - rare_count = 0 - for inv in items: - if inv.item_id < len(ALL_ITEMS): - rar = ALL_ITEMS[inv.item_id].get("rarity", "") - if rar in ("Covert", "Extraordinary", "Rare Special Item"): - rare_count += 1 + 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) @@ -2498,11 +2477,11 @@ def check_rare_item_count_achievements(db: Session, user_id: int): def check_knife_count_achievements(db: Session, user_id: int): from database import InventoryItem from backend import ALL_ITEMS, is_knife, is_gloves - items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all() knife_count = 0 - for inv in items: - if inv.item_id < len(ALL_ITEMS): - item_data = ALL_ITEMS[inv.item_id] + 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 = [] diff --git a/admin.py b/admin.py index 9c108be..cb6779b 100644 --- a/admin.py +++ b/admin.py @@ -8,7 +8,7 @@ import json from datetime import datetime from pathlib import Path -from database import get_db, User, InventoryItem, CaseOpening, Contract +from database import get_db, User, InventoryItem, CaseOpening, Contract, Upgrade, TransactionLog from auth import get_current_admin from rpu import get_user_rpu, calculate_rpu_level, get_rpu_description @@ -184,6 +184,48 @@ async def admin_user_detail( "inventory": inventory }) +@admin_router.get("/user/{user_id}/history", response_class=HTMLResponse) +async def admin_user_history( + user_id: int, + request: Request, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin) +): + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(404, "Пользователь не найден") + + # Все открытия кейсов + openings = db.query(CaseOpening).filter( + CaseOpening.user_id == user_id + ).order_by(desc(CaseOpening.opened_at)).limit(200).all() + + # Все апгрейды + upgrades = db.query(Upgrade).filter( + Upgrade.user_id == user_id + ).order_by(desc(Upgrade.created_at)).limit(200).all() + + # Все транзакции + transactions = db.query(TransactionLog).filter( + TransactionLog.user_id == user_id + ).order_by(desc(TransactionLog.created_at)).limit(200).all() + + from rpu import calculate_ceiling, get_user_total_value, get_user_rpu + ceiling = calculate_ceiling(db, user_id) + total_value = get_user_total_value(db, user_id) + rpu = get_user_rpu(db, user_id) + + return templates.TemplateResponse(request, "admin/user_history.html", { + "user": admin, + "target_user": target_user, + "openings": openings, + "upgrades": upgrades, + "transactions": transactions, + "ceiling": ceiling, + "total_value": total_value, + "rpu": rpu, + }) + @admin_router.get("/cases", response_class=HTMLResponse) async def admin_cases( request: Request, @@ -197,6 +239,7 @@ async def admin_cases( for case in cases: if not isinstance(case.get('items'), list): case['items'] = [] + case['items_count'] = len(case['items']) all_items = [] for i, item in enumerate(ALL_ITEMS[:500]): @@ -272,7 +315,7 @@ async def admin_give_item( if not item_data: return JSONResponse(status_code=404, content={"error": "Предмет не найден"}) - item_float = generate_item_float(item_data.get("rarity", "Unknown")) + item_float = item_data.get("mid_float_used") or generate_item_float(item_data.get("rarity", "Unknown")) inventory_item = InventoryItem( user_id=user_id, @@ -282,6 +325,8 @@ async def admin_give_item( wear=item_data.get("wear", "Unknown"), float_value=item_float, type=item_data.get("type", "Normal"), + image_url=item_data.get("image_url", ""), + price_rub=item_data.get("price_rub", 0), obtained_from="admin" ) db.add(inventory_item) @@ -370,6 +415,13 @@ async def admin_get_user_rpu( rpu = get_user_rpu(db, user_id) + from promo_phase import get_promo_phase_info + promo = get_promo_phase_info() + + # Потолок + from rpu import calculate_ceiling + ceiling = calculate_ceiling(db, user_id) + return { "user_id": user_id, "username": target_user.username, @@ -387,7 +439,44 @@ async def admin_get_user_rpu( "rpu_level": calculate_rpu_level(rpu), "stats": { "total_spent": rpu.total_spent, - "total_opened": rpu.total_opened + "total_opened": rpu.total_opened, + "total_value_received": rpu.total_value_received, + "current_streak": rpu.current_streak, + "best_streak": rpu.best_streak, + "worst_streak": rpu.worst_streak + }, + # ── RPU v2 ── + "v2": { + "ceiling": { + "total_deposited": target_user.total_deposited or 0, + "ceiling_multiplier": rpu.ceiling_multiplier, + "current_ceiling": ceiling, + "break_count": rpu.ceiling_break_count, + "break_session": rpu.ceiling_break_session, + }, + "session": { + "luck_budget": round(rpu.luck_budget, 1), + "session_spent": round(rpu.session_spent, 0), + "session_won": round(rpu.session_won, 0), + "reset_date": rpu.session_reset_date.isoformat() if rpu.session_reset_date else None, + }, + "comeback": { + "active": rpu.comeback_active, + "openings_left": rpu.comeback_openings_left, + "multiplier": rpu.comeback_multiplier, + "consecutive_loss_value": round(rpu.consecutive_loss_value or 0, 0), + "consecutive_loss_count": rpu.consecutive_loss_count or 0, + }, + "hot_cold": { + "hot_score": round(rpu.hot_score or 0, 2), + "last_activity": rpu.last_activity_date.isoformat() if rpu.last_activity_date else None, + }, + "promo_phase": { + "phase": promo["phase"], + "effect": promo["effect"], + "next_switch": promo["next_switch"], + "remaining_minutes": promo["remaining_minutes"], + } } } @@ -481,30 +570,30 @@ async def admin_set_rpu_preset( @admin_router.post("/api/cases/create") async def admin_create_case( - name: str = Form(...), + case_name: str = Form(...), display_name: str = Form(...), - price: float = Form(...), + price_open: float = Form(...), description: str = Form(""), image_url: str = Form(""), - items: str = Form(...), + items_json: str = Form(...), admin: User = Depends(get_current_admin) ): cases = load_cases_config() for case in cases: - if case['name'] == name: + if case['name'] == case_name: return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"}) try: - item_ids = json.loads(items) + parsed_items = json.loads(items_json) except: return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"}) new_case = { - "name": name, + "name": case_name, "display_name": display_name, - "price_open": price, - "items": item_ids, + "price_open": price_open, + "items": parsed_items, "image_url": image_url, "description": description } @@ -522,10 +611,11 @@ async def admin_create_case( async def admin_update_case( case_name: str, display_name: str = Form(...), - price: float = Form(...), + price_open: float = Form(...), description: str = Form(""), image_url: str = Form(""), - items: str = Form(...), + items_json: str = Form(...), + new_name: str = Form(""), admin: User = Depends(get_current_admin) ): cases = load_cases_config() @@ -533,18 +623,34 @@ async def admin_update_case( for case in cases: if case['name'] == case_name: try: - item_ids = json.loads(items) + parsed_items = json.loads(items_json) except: return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"}) + target_name = new_name or case_name + + # Проверяем конфликт имени при переименовании + if new_name and new_name != case_name: + for c in cases: + if c['name'] == new_name: + return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"}) + case['name'] = new_name + case['display_name'] = display_name - case['price_open'] = price + case['price_open'] = price_open case['description'] = description case['image_url'] = image_url - case['items'] = item_ids + case['items'] = parsed_items save_cases_config(cases) + # Обновляем mainpage если переименован + if new_name and new_name != case_name: + mainpage = load_mainpage_config() + for section in mainpage: + section['cases'] = [new_name if c == case_name else c for c in section['cases']] + save_mainpage_config(mainpage) + return JSONResponse({ "success": True, "case": case, @@ -594,58 +700,91 @@ async def admin_search_items( q: str = "", min_price: float = 0, max_price: float = 0, - limit: int = 200 + limit: int = 200, + rarity_filter: str = "", + collection_filter: str = "", + type_filter: str = "", + weapon_filter: str = "", + sort_by: str = "", + sort_order: str = "asc", + include_subvariants: bool = False ): - """Поиск предметов для добавления в кейс / апгрейда""" + """Поиск предметов для добавления в кейс / апгрейда.""" from frontend import ALL_ITEMS - + from backend import COLLECTIONS_INDEX + query = q.lower().strip() - results = [] - + seen_bases = set() + def accept_item(item, idx): + if not include_subvariants and item.get("is_subvariant"): + return False if min_price > 0 and item.get("price_rub", 0) < min_price: return False if max_price > 0 and item.get("price_rub", 0) > max_price: return False + if rarity_filter: + item_rarity = item.get("rarity", "").lower() + if rarity_filter.lower() not in item_rarity: + return False + if collection_filter: + item_coll = item.get("collection", "").lower() + if collection_filter.lower() not in item_coll: + return False + if type_filter: + item_type = item.get("type", "").lower() + if type_filter.lower() != item_type: + return False + if weapon_filter: + item_weapon = item.get("weapon", "").lower() + if weapon_filter.lower() != item_weapon: + return False return True - + + def make_result(item, idx): + name = item.get("market_hash_name", "Unknown") + dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}" + if dedup_key in seen_bases: + return None + seen_bases.add(dedup_key) + return { + "id": item.get("_id", idx), + "name": name, + "rarity": item.get("rarity", "Unknown"), + "image_url": item.get("image_url", ""), + "price_rub": item.get("price_rub", 0), + "weapon": item.get("weapon", ""), + "pattern": item.get("pattern", ""), + "collection": item.get("collection", ""), + "type": item.get("type", "Normal"), + } + + if query and len(query) < 2: + return [] + if not query or len(query) < 2: - # Без поиска возвращаем разнообразные предметы со всего ассортимента - # Если есть ценовой фильтр — проходим по всем, иначе шагами - if min_price > 0 or max_price > 0: + if any([min_price > 0, max_price > 0, rarity_filter, collection_filter, + type_filter, weapon_filter]): for idx, item in enumerate(ALL_ITEMS): - if accept_item(item, idx): - item_id = item.get("_id", idx) - results.append({ - "id": item_id, - "name": item.get("market_hash_name", "Unknown"), - "rarity": item.get("rarity", "Unknown"), - "image_url": item.get("image_url", ""), - "price_rub": item.get("price_rub", 0), - "weapon": item.get("weapon", ""), - "pattern": item.get("pattern", "") - }) + if not accept_item(item, idx): + continue + r = make_result(item, idx) + if r: + results.append(r) if len(results) >= limit: break else: step = max(1, len(ALL_ITEMS) // limit) for idx in range(0, len(ALL_ITEMS), step): - if not accept_item(ALL_ITEMS[idx], idx): - continue item = ALL_ITEMS[idx] - item_id = item.get("_id", idx) - results.append({ - "id": item_id, - "name": item.get("market_hash_name", "Unknown"), - "rarity": item.get("rarity", "Unknown"), - "image_url": item.get("image_url", ""), - "price_rub": item.get("price_rub", 0), - "weapon": item.get("weapon", ""), - "pattern": item.get("pattern", "") - }) - if len(results) >= 200: - break + if not accept_item(item, idx): + continue + r = make_result(item, idx) + if r: + results.append(r) + if len(results) >= limit: + break else: for idx, item in enumerate(ALL_ITEMS): if not accept_item(item, idx): @@ -653,24 +792,113 @@ async def admin_search_items( name = item.get("market_hash_name", "").lower() weapon = item.get("weapon", "").lower() pattern = item.get("pattern", "").lower() - - if query in name or query in weapon or query in pattern: - item_id = item.get("_id", idx) - results.append({ - "id": item_id, - "name": item.get("market_hash_name", "Unknown"), - "rarity": item.get("rarity", "Unknown"), - "image_url": item.get("image_url", ""), - "price_rub": item.get("price_rub", 0), - "weapon": item.get("weapon", ""), - "pattern": item.get("pattern", "") - }) - if len(results) >= 200: - break - + coll = item.get("collection", "").lower() + + if query in name or query in weapon or query in pattern or query in coll: + r = make_result(item, idx) + if r: + results.append(r) + if len(results) >= limit: + break + + # Сортировка + if sort_by in ("price_rub", "name", "rarity"): + reverse = sort_order == "desc" + if sort_by == "rarity": + rarity_rank = { + "Consumer Grade": 0, "Industrial Grade": 1, + "Mil-Spec": 2, "Mil-Spec Grade": 2, + "Restricted": 3, "Classified": 4, + "Covert": 5, "Rare Special Item": 6, + "Extraordinary": 6, "Contraband": 7 + } + results.sort(key=lambda x: rarity_rank.get(x.get("rarity", ""), 0), + reverse=reverse) + else: + results.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse) + return results[:200] +@admin_router.get("/api/items/filters") +async def admin_items_filters(): + """Возвращает списки уникальных значений для фильтров""" + from frontend import ALL_ITEMS + + weapons = set() + types = set() + for item in ALL_ITEMS: + if item.get("is_subvariant"): + continue + w = item.get("weapon", "") + if w: + weapons.add(w) + t = item.get("type", "") + if t: + types.add(t) + + return { + "weapons": sorted(weapons), + "types": sorted(types), + } + + +@admin_router.get("/api/collections") +async def admin_list_collections(): + """Возвращает список всех коллекций для фильтра""" + from backend import COLLECTIONS_INDEX + return {"collections": sorted(COLLECTIONS_INDEX.keys())} + + +@admin_router.get("/api/items/by-collection") +async def admin_items_by_collection( + collection: str = "", + rarity_filter: str = "", + type_filter: str = "", + weapon_filter: str = "", + include_subvariants: bool = False +): + """Возвращает предметы из указанной коллекции""" + from backend import COLLECTIONS_INDEX + from frontend import get_item + results = [] + seen_bases = set() + if collection not in COLLECTIONS_INDEX: + return results + for idx in COLLECTIONS_INDEX[collection]: + item = get_item(idx) + if not item: + continue + if not include_subvariants and item.get("is_subvariant"): + continue + if rarity_filter: + if rarity_filter.lower() not in item.get("rarity", "").lower(): + continue + if type_filter: + if type_filter.lower() != item.get("type", "").lower(): + continue + if weapon_filter: + if weapon_filter.lower() != item.get("weapon", "").lower(): + continue + name = item.get("market_hash_name", "Unknown") + dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}" + if dedup_key in seen_bases: + continue + seen_bases.add(dedup_key) + results.append({ + "id": item.get("_id", idx), + "name": name, + "rarity": item.get("rarity", "Unknown"), + "image_url": item.get("image_url", ""), + "price_rub": item.get("price_rub", 0), + "weapon": item.get("weapon", ""), + "pattern": item.get("pattern", ""), + "collection": item.get("collection", ""), + "type": item.get("type", "Normal"), + }) + return results + + @admin_router.post("/api/mainpage/section/add") async def admin_add_mainpage_section( tab: str = Form(...), @@ -787,6 +1015,129 @@ async def admin_get_item( return JSONResponse(status_code=404, content={"error": "Предмет не найден"}) +# ========== API ПРОМОКОДЫ ========== + +@admin_router.get("/api/promocodes") +async def admin_list_promocodes( + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin) +): + codes = db.query(PromoCode).order_by(desc(PromoCode.created_at)).limit(100).all() + return [{ + "id": c.id, + "code": c.code, + "reward_type": c.reward_type, + "reward_amount": c.reward_amount, + "max_uses": c.max_uses, + "used_count": c.used_count, + "start_date": c.start_date.isoformat() if c.start_date else None, + "end_date": c.end_date.isoformat() if c.end_date else None, + "is_active": c.is_active, + } for c in codes] + + +@admin_router.post("/api/promocodes/create") +async def admin_create_promocode( + code: str = Form(...), + reward_type: str = Form(...), + reward_amount: float = Form(0), + reward_data: str = Form(""), + max_uses: int = Form(1), + end_date_str: str = Form(""), + is_active: bool = Form(True), + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin) +): + existing = db.query(PromoCode).filter(PromoCode.code == code).first() + if existing: + return JSONResponse(status_code=400, content={"error": "Промокод с таким кодом уже существует"}) + + end_date = None + if end_date_str: + try: + end_date = datetime.fromisoformat(end_date_str) + except: + pass + + promo = PromoCode( + code=code, + reward_type=reward_type, + reward_amount=reward_amount, + reward_data=reward_data, + max_uses=max_uses, + end_date=end_date, + is_active=is_active, + created_by=admin.id, + ) + db.add(promo) + db.commit() + + return {"success": True, "message": f"Промокод '{code}' создан"} + + +@admin_router.post("/api/promocodes/toggle/{promo_id}") +async def admin_toggle_promocode( + promo_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin) +): + promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first() + if not promo: + return JSONResponse(status_code=404, content={"error": "Промокод не найден"}) + promo.is_active = not promo.is_active + db.commit() + return {"success": True, "is_active": promo.is_active} + + +@admin_router.post("/api/promocodes/delete/{promo_id}") +async def admin_delete_promocode( + promo_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin) +): + promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first() + if not promo: + return JSONResponse(status_code=404, content={"error": "Промокод не найден"}) + db.delete(promo) + db.commit() + return {"success": True, "message": "Промокод удален"} + + +@admin_router.get("/api/promo-phase") +async def admin_get_promo_phase( + admin: User = Depends(get_current_admin) +): + """Информация о текущей промо-фазе (для админки)""" + from promo_phase import get_phase_debug_info + return get_phase_debug_info() + + +# ========== API ТРАНЗАКЦИИ ========== + +@admin_router.get("/api/transactions") +async def admin_list_transactions( + user_id: Optional[int] = None, + limit: int = 100, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin) +): + query = db.query(TransactionLog) + if user_id: + query = query.filter(TransactionLog.user_id == user_id) + txns = query.order_by(desc(TransactionLog.created_at)).limit(limit).all() + return [{ + "id": t.id, + "user_id": t.user_id, + "tx_type": t.tx_type, + "amount": t.amount, + "fee": t.fee, + "promo_code": t.promo_code, + "item_name": t.item_name, + "details": t.details, + "created_at": t.created_at.isoformat(), + } for t in txns] + + @admin_router.post("/api/reset-password/{user_id}") async def admin_reset_password( user_id: int, diff --git a/bank_api.py b/bank_api.py new file mode 100644 index 0000000..2c0e0a3 --- /dev/null +++ b/bank_api.py @@ -0,0 +1,107 @@ +""" +BankAPI — внешний сервис для управления "картой" пользователя. +Сейчас заглушка (mock), архитектура под настоящий REST. +""" + +import random +from datetime import datetime, timedelta +from typing import Optional + + +class BankAPIClient: + """ + Клиент для взаимодействия с Bank API. + В будущем — HTTP-клиент к внешнему сервису. + Сейчас — in-memory заглушка. + """ + + def __init__(self, base_url: str = "http://bank-api:8000"): + self.base_url = base_url + # mock: user_id -> {balance, transactions} + self._mock_balances: dict[int, float] = {} + self._mock_txns: dict[int, list] = {} + self._initial_balance = 1000.0 + + def _ensure_user(self, user_id: int): + if user_id not in self._mock_balances: + self._mock_balances[user_id] = self._initial_balance + self._mock_txns[user_id] = [] + + async def get_balance(self, user_id: int) -> float: + """Сколько денег на карте пользователя""" + self._ensure_user(user_id) + return self._mock_balances[user_id] + + async def transfer_to_site(self, user_id: int, amount: float, + promo_code: Optional[str] = None) -> dict: + """ + Перевод с карты на баланс сайта. + Возвращает: {success, amount, new_card_balance, promo_effect} + """ + self._ensure_user(user_id) + if self._mock_balances[user_id] < amount: + return {"success": False, "error": "Недостаточно средств на карте"} + + self._mock_balances[user_id] -= amount + self._mock_txns[user_id].append({ + "type": "card_to_site", + "amount": amount, + "promo": promo_code, + "timestamp": datetime.utcnow().isoformat() + }) + + return { + "success": True, + "amount": amount, + "new_card_balance": self._mock_balances[user_id], + "promo_effect": None + } + + async def withdraw_to_card(self, user_id: int, amount: float) -> dict: + """ + Вывод с баланса сайта на карту. + Возвращает: {success, amount, new_card_balance, fee} + """ + self._ensure_user(user_id) + fee = round(amount * 0.10, 2) # 10% комиссия + net = round(amount - fee, 2) + + self._mock_balances[user_id] += net + self._mock_txns[user_id].append({ + "type": "site_to_card", + "gross": amount, + "fee": fee, + "net": net, + "timestamp": datetime.utcnow().isoformat() + }) + + return { + "success": True, + "amount": amount, + "fee": fee, + "net": net, + "new_card_balance": self._mock_balances[user_id] + } + + async def validate_promo(self, user_id: int, promo_code: str) -> dict: + """ + Проверяет промокод. Возвращает его эффект для перевода card->site. + """ + # Заглушка — реальная логика будет в PromoCode сервисе + return { + "valid": True, + "promo_type": "card_to_site", + "amount": 0, + "luck_boost": 0, + "ceiling_boost": 0 + } + + async def get_transaction_history(self, user_id: int, + limit: int = 50) -> list: + """История транзакций по карте""" + self._ensure_user(user_id) + return self._mock_txns[user_id][-limit:] + + +# Singleton +bank_client = BankAPIClient() diff --git a/cases.json b/cases.json index 061a252..4a059c6 100644 --- a/cases.json +++ b/cases.json @@ -10,7 +10,7 @@ { "name": "Recoil Case", "display_name": "Recoil Case", - "price_open": 50.0, + "price_open": 110.0, "items": [], "image_url": "", "description": "Скины из коллекции Recoil" @@ -1456,5 +1456,1895 @@ "items": [], "image_url": "", "description": "Самые сомнительные предметы коллекции Dildo" + }, + { + "name": "Revolvernoe", + "display_name": "Revolver Case", + "price_open": 4500.0, + "items": [ + { + "id": 12977, + "name": "R8 Revolver | Amber Fade (Factory New)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMWqV1e96vOhqcDu2gxIrpTiXpYPwJiPTcAIpDJckF-9cuhfqltDuZujgs1DZj4hDy338jnhM73xusOcKVaos-qPJz1aW9R0yRq8", + "price_rub": 205.34, + "weapon": "R8 Revolver", + "pattern": "Amber Fade", + "collection": "The Dust 2 Collection", + "type": "Normal" + }, + { + "id": 12979, + "name": "R8 Revolver | Amber Fade (Minimal Wear)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMWqV1e96vOhqcDu2gxIrpTiXpYPwJiPTcAIpDJckF-9cuhfqltDuZujgs1DZj4hDy338jnhM73xusOcKVaos-qPJz1aW9R0yRq8", + "price_rub": 100.39, + "weapon": "R8 Revolver", + "pattern": "Amber Fade", + "collection": "The Dust 2 Collection", + "type": "Normal" + }, + { + "id": 12980, + "name": "Souvenir R8 Revolver | Amber Fade (Minimal Wear)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMWqV1e96vOhqcDu2gxIrpTiXpYPwJiPTcAIpDJckF-9cuhfqltDuZujgs1DZj4hDy338jnhM73xusOcKVaos-qPJz1aW9R0yRq8", + "price_rub": 7426.62, + "weapon": "R8 Revolver", + "pattern": "Amber Fade", + "collection": "The Dust 2 Collection", + "type": "Souvenir" + }, + { + "id": 12981, + "name": "R8 Revolver | Amber Fade (Field-Tested)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMWqV1e96vOhqcDu2gxIrpTiXpYPwJiPTcAIpDJckF-9cuhfqltDuZujgs1DZj4hDy338jnhM73xusOcKVaos-qPJz1aW9R0yRq8", + "price_rub": 94.31, + "weapon": "R8 Revolver", + "pattern": "Amber Fade", + "collection": "The Dust 2 Collection", + "type": "Normal" + }, + { + "id": 12982, + "name": "Souvenir R8 Revolver | Amber Fade (Field-Tested)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMWqV1e96vOhqcDu2gxIrpTiXpYPwJiPTcAIpDJckF-9cuhfqltDuZujgs1DZj4hDy338jnhM73xusOcKVaos-qPJz1aW9R0yRq8", + "price_rub": 7247.9, + "weapon": "R8 Revolver", + "pattern": "Amber Fade", + "collection": "The Dust 2 Collection", + "type": "Souvenir" + }, + { + "id": 12983, + "name": "R8 Revolver | Amber Fade (Well-Worn)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMWqV1e96vOhqcDu2gxIrpTiXpYPwJiPTcAIpDJckF-9cuhfqltDuZujgs1DZj4hDy338jnhM73xusOcKVaos-qPJz1aW9R0yRq8", + "price_rub": 242.61, + "weapon": "R8 Revolver", + "pattern": "Amber Fade", + "collection": "The Dust 2 Collection", + "type": "Normal" + }, + { + "id": 12984, + "name": "Souvenir R8 Revolver | Amber Fade (Well-Worn)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMWqV1e96vOhqcDu2gxIrpTiXpYPwJiPTcAIpDJckF-9cuhfqltDuZujgs1DZj4hDy338jnhM73xusOcKVaos-qPJz1aW9R0yRq8", + "price_rub": 11.41, + "weapon": "R8 Revolver", + "pattern": "Amber Fade", + "collection": "The Dust 2 Collection", + "type": "Souvenir" + }, + { + "id": 12985, + "name": "R8 Revolver | Fade (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMXWV1-F6puR7cCW6khUz_W2EnIuheSiVZgIgX8F3Ee9YthLuw9LjMePqswCL34kQzHj6iypPu351o7FV6jjqgC8", + "price_rub": 9021.47, + "weapon": "R8 Revolver", + "pattern": "Fade", + "collection": "The Revolver Case Collection", + "type": "Normal" + }, + { + "id": 12986, + "name": "StatTrak™ R8 Revolver | Fade (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMXWV1-F6puR7cCW6khUz_W2EnIuheSiVZgIgX8F3Ee9YthLuw9LjMePqswCL34kQzHj6iypPu351o7FV6jjqgC8", + "price_rub": 8914.23, + "weapon": "R8 Revolver", + "pattern": "Fade", + "collection": "The Revolver Case Collection", + "type": "StatTrak" + }, + { + "id": 12987, + "name": "R8 Revolver | Fade (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMXWV1-F6puR7cCW6khUz_W2EnIuheSiVZgIgX8F3Ee9YthLuw9LjMePqswCL34kQzHj6iypPu351o7FV6jjqgC8", + "price_rub": 6467.59, + "weapon": "R8 Revolver", + "pattern": "Fade", + "collection": "The Revolver Case Collection", + "type": "Normal" + }, + { + "id": 12988, + "name": "StatTrak™ R8 Revolver | Fade (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMXWV1-F6puR7cCW6khUz_W2EnIuheSiVZgIgX8F3Ee9YthLuw9LjMePqswCL34kQzHj6iypPu351o7FV6jjqgC8", + "price_rub": 6467.59, + "weapon": "R8 Revolver", + "pattern": "Fade", + "collection": "The Revolver Case Collection", + "type": "StatTrak" + }, + { + "id": 12989, + "name": "R8 Revolver | Fade (Field-Tested)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMXWV1-F6puR7cCW6khUz_W2EnIuheSiVZgIgX8F3Ee9YthLuw9LjMePqswCL34kQzHj6iypPu351o7FV6jjqgC8", + "price_rub": 6512.46, + "weapon": "R8 Revolver", + "pattern": "Fade", + "collection": "The Revolver Case Collection", + "type": "Normal" + }, + { + "id": 12990, + "name": "StatTrak™ R8 Revolver | Fade (Field-Tested)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMXWV1-F6puR7cCW6khUz_W2EnIuheSiVZgIgX8F3Ee9YthLuw9LjMePqswCL34kQzHj6iypPu351o7FV6jjqgC8", + "price_rub": 6690.43, + "weapon": "R8 Revolver", + "pattern": "Fade", + "collection": "The Revolver Case Collection", + "type": "StatTrak" + }, + { + "id": 12991, + "name": "R8 Revolver | Fade (Well-Worn)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMXWV1-F6puR7cCW6khUz_W2EnIuheSiVZgIgX8F3Ee9YthLuw9LjMePqswCL34kQzHj6iypPu351o7FV6jjqgC8", + "price_rub": 6668.37, + "weapon": "R8 Revolver", + "pattern": "Fade", + "collection": "The Revolver Case Collection", + "type": "Normal" + }, + { + "id": 12992, + "name": "StatTrak™ R8 Revolver | Fade (Well-Worn)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMXWV1-F6puR7cCW6khUz_W2EnIuheSiVZgIgX8F3Ee9YthLuw9LjMePqswCL34kQzHj6iypPu351o7FV6jjqgC8", + "price_rub": 11150.96, + "weapon": "R8 Revolver", + "pattern": "Fade", + "collection": "The Revolver Case Collection", + "type": "StatTrak" + }, + { + "id": 12993, + "name": "R8 Revolver | Blaze (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrLPGeC3SvzedxuPUnTCznkR9xsGiHzomgIn-UPwN1DZZyELNe5xDrlNTgY-jm5FeM3tkUnDK-0H3jUXrCJQ", + "price_rub": 334.64, + "weapon": "R8 Revolver", + "pattern": "Blaze", + "collection": "The 2021 Train Collection", + "type": "Normal" + }, + { + "id": 12994, + "name": "R8 Revolver | Blaze (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrLPGeC3SvzedxuPUnTCznkR9xsGiHzomgIn-UPwN1DZZyELNe5xDrlNTgY-jm5FeM3tkUnDK-0H3jUXrCJQ", + "price_rub": 301.17, + "weapon": "R8 Revolver", + "pattern": "Blaze", + "collection": "The 2021 Train Collection", + "type": "Normal" + }, + { + "id": 12995, + "name": "R8 Revolver | Phoenix Marker (Factory New)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vZZt9KP-WAG6I_vp3t_JWTSWmkCIrujqNjsH9dSnGZgQiC5Z5EOQP5ETsmtLjNuzi5lPe3tpEmyj9ji0a6Cxp4blRT-N7rZuorHqs", + "price_rub": 1951.53, + "weapon": "R8 Revolver", + "pattern": "Phoenix Marker", + "collection": "The Havoc Collection", + "type": "Normal" + }, + { + "id": 12996, + "name": "R8 Revolver | Phoenix Marker (Minimal Wear)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vZZt9KP-WAG6I_vp3t_JWTSWmkCIrujqNjsH9dSnGZgQiC5Z5EOQP5ETsmtLjNuzi5lPe3tpEmyj9ji0a6Cxp4blRT-N7rZuorHqs", + "price_rub": 554.43, + "weapon": "R8 Revolver", + "pattern": "Phoenix Marker", + "collection": "The Havoc Collection", + "type": "Normal" + }, + { + "id": 12997, + "name": "R8 Revolver | Phoenix Marker (Field-Tested)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vZZt9KP-WAG6I_vp3t_JWTSWmkCIrujqNjsH9dSnGZgQiC5Z5EOQP5ETsmtLjNuzi5lPe3tpEmyj9ji0a6Cxp4blRT-N7rZuorHqs", + "price_rub": 384.83, + "weapon": "R8 Revolver", + "pattern": "Phoenix Marker", + "collection": "The Havoc Collection", + "type": "Normal" + }, + { + "id": 12998, + "name": "R8 Revolver | Phoenix Marker (Well-Worn)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vZZt9KP-WAG6I_vp3t_JWTSWmkCIrujqNjsH9dSnGZgQiC5Z5EOQP5ETsmtLjNuzi5lPe3tpEmyj9ji0a6Cxp4blRT-N7rZuorHqs", + "price_rub": 445.67, + "weapon": "R8 Revolver", + "pattern": "Phoenix Marker", + "collection": "The Havoc Collection", + "type": "Normal" + }, + { + "id": 12999, + "name": "R8 Revolver | Phoenix Marker (Battle-Scarred)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vZZt9KP-WAG6I_vp3t_JWTSWmkCIrujqNjsH9dSnGZgQiC5Z5EOQP5ETsmtLjNuzi5lPe3tpEmyj9ji0a6Cxp4blRT-N7rZuorHqs", + "price_rub": 364.3, + "weapon": "R8 Revolver", + "pattern": "Phoenix Marker", + "collection": "The Havoc Collection", + "type": "Normal" + }, + { + "id": 13000, + "name": "R8 Revolver | Leafhopper (Factory New)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2veZt5KeSSAG6FzNF3s-htcCW6khUz_W-Gy96pJXmWOwJyX8QlF7Zeshm5wYbiMOvm5laLjohNxCis3S8a5311o7FVOU-zWog", + "price_rub": 21.29, + "weapon": "R8 Revolver", + "pattern": "Leafhopper", + "collection": "The Boreal Collection", + "type": "Normal" + }, + { + "id": 13001, + "name": "R8 Revolver | Leafhopper (Minimal Wear)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2veZt5KeSSAG6FzNF3s-htcCW6khUz_W-Gy96pJXmWOwJyX8QlF7Zeshm5wYbiMOvm5laLjohNxCis3S8a5311o7FVOU-zWog", + "price_rub": 8.37, + "weapon": "R8 Revolver", + "pattern": "Leafhopper", + "collection": "The Boreal Collection", + "type": "Normal" + }, + { + "id": 13002, + "name": "R8 Revolver | Leafhopper (Field-Tested)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2veZt5KeSSAG6FzNF3s-htcCW6khUz_W-Gy96pJXmWOwJyX8QlF7Zeshm5wYbiMOvm5laLjohNxCis3S8a5311o7FVOU-zWog", + "price_rub": 3.04, + "weapon": "R8 Revolver", + "pattern": "Leafhopper", + "collection": "The Boreal Collection", + "type": "Normal" + }, + { + "id": 13003, + "name": "R8 Revolver | Leafhopper (Well-Worn)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2veZt5KeSSAG6FzNF3s-htcCW6khUz_W-Gy96pJXmWOwJyX8QlF7Zeshm5wYbiMOvm5laLjohNxCis3S8a5311o7FVOU-zWog", + "price_rub": 3.8, + "weapon": "R8 Revolver", + "pattern": "Leafhopper", + "collection": "The Boreal Collection", + "type": "Normal" + }, + { + "id": 13004, + "name": "R8 Revolver | Leafhopper (Battle-Scarred)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2veZt5KeSSAG6FzNF3s-htcCW6khUz_W-Gy96pJXmWOwJyX8QlF7Zeshm5wYbiMOvm5laLjohNxCis3S8a5311o7FVOU-zWog", + "price_rub": 2.28, + "weapon": "R8 Revolver", + "pattern": "Leafhopper", + "collection": "The Boreal Collection", + "type": "Normal" + }, + { + "id": 13005, + "name": "R8 Revolver | Reboot (Factory New)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 1373.53, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "Normal" + }, + { + "id": 13006, + "name": "StatTrak™ R8 Revolver | Reboot (Factory New)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 1471.64, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "StatTrak" + }, + { + "id": 13007, + "name": "R8 Revolver | Reboot (Minimal Wear)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 231.96, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "Normal" + }, + { + "id": 13008, + "name": "StatTrak™ R8 Revolver | Reboot (Minimal Wear)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 396.24, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "StatTrak" + }, + { + "id": 13009, + "name": "R8 Revolver | Reboot (Field-Tested)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 98.87, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "Normal" + }, + { + "id": 13010, + "name": "StatTrak™ R8 Revolver | Reboot (Field-Tested)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 182.53, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "StatTrak" + }, + { + "id": 13011, + "name": "R8 Revolver | Reboot (Well-Worn)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 89.74, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "Normal" + }, + { + "id": 13012, + "name": "StatTrak™ R8 Revolver | Reboot (Well-Worn)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 122.45, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "StatTrak" + }, + { + "id": 13013, + "name": "R8 Revolver | Reboot (Battle-Scarred)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 92.02, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "Normal" + }, + { + "id": 13014, + "name": "StatTrak™ R8 Revolver | Reboot (Battle-Scarred)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-QF2WV0_1mv_N9cCW6khUz_WjRyouseHqUPwQoXMNyQbMM4ETulNSxNuq3s1DZi95CzH_-2Cgf7Hp1o7FVySZFkkA", + "price_rub": 114.08, + "weapon": "R8 Revolver", + "pattern": "Reboot", + "collection": "The Gamma Collection", + "type": "StatTrak" + }, + { + "id": 13015, + "name": "R8 Revolver | Survivalist (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 479.14, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "Normal" + }, + { + "id": 13016, + "name": "StatTrak™ R8 Revolver | Survivalist (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 114.84, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "StatTrak" + }, + { + "id": 13017, + "name": "R8 Revolver | Survivalist (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 38.03, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "Normal" + }, + { + "id": 13019, + "name": "R8 Revolver | Survivalist (Field-Tested)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 25.86, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "Normal" + }, + { + "id": 13018, + "name": "StatTrak™ R8 Revolver | Survivalist (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 49.43, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "StatTrak" + }, + { + "id": 13020, + "name": "StatTrak™ R8 Revolver | Survivalist (Field-Tested)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 24.34, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "StatTrak" + }, + { + "id": 13021, + "name": "R8 Revolver | Survivalist (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 27.38, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "Normal" + }, + { + "id": 13022, + "name": "StatTrak™ R8 Revolver | Survivalist (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 44.87, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "StatTrak" + }, + { + "id": 13023, + "name": "R8 Revolver | Survivalist (Battle-Scarred)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 22.82, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "Normal" + }, + { + "id": 13024, + "name": "StatTrak™ R8 Revolver | Survivalist (Battle-Scarred)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_eM-AG3WGyPh3vOh6Wxa_nBovp3OBzditcSmSOFUnDsEiE-4MuhPqw4bmY-jisQff2dhGnCyq2n4a631v_a9cBkWPt9yi", + "price_rub": 35.75, + "weapon": "R8 Revolver", + "pattern": "Survivalist", + "collection": "The Horizon Collection", + "type": "StatTrak" + }, + { + "id": 13025, + "name": "R8 Revolver | Skull Crusher (Field-Tested)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_JeacAnGV09F5oPF7Sjqgmg8YvjiBk5r0b3uSaQUiCsZ2E-EI5EKwldW2NOzqslffgopHxHj-jHxLvH4_tbwHWaQ7uvqApie1xz8", + "price_rub": 803.12, + "weapon": "R8 Revolver", + "pattern": "Skull Crusher", + "collection": "The Prisma Collection", + "type": "Normal" + }, + { + "id": 13026, + "name": "StatTrak™ R8 Revolver | Skull Crusher (Field-Tested)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_JeacAnGV09F5oPF7Sjqgmg8YvjiBk5r0b3uSaQUiCsZ2E-EI5EKwldW2NOzqslffgopHxHj-jHxLvH4_tbwHWaQ7uvqApie1xz8", + "price_rub": 875.38, + "weapon": "R8 Revolver", + "pattern": "Skull Crusher", + "collection": "The Prisma Collection", + "type": "StatTrak" + }, + { + "id": 13027, + "name": "R8 Revolver | Skull Crusher (Well-Worn)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_JeacAnGV09F5oPF7Sjqgmg8YvjiBk5r0b3uSaQUiCsZ2E-EI5EKwldW2NOzqslffgopHxHj-jHxLvH4_tbwHWaQ7uvqApie1xz8", + "price_rub": 457.08, + "weapon": "R8 Revolver", + "pattern": "Skull Crusher", + "collection": "The Prisma Collection", + "type": "Normal" + }, + { + "id": 13028, + "name": "StatTrak™ R8 Revolver | Skull Crusher (Well-Worn)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_JeacAnGV09F5oPF7Sjqgmg8YvjiBk5r0b3uSaQUiCsZ2E-EI5EKwldW2NOzqslffgopHxHj-jHxLvH4_tbwHWaQ7uvqApie1xz8", + "price_rub": 500.43, + "weapon": "R8 Revolver", + "pattern": "Skull Crusher", + "collection": "The Prisma Collection", + "type": "StatTrak" + }, + { + "id": 13029, + "name": "R8 Revolver | Skull Crusher (Battle-Scarred)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_JeacAnGV09F5oPF7Sjqgmg8YvjiBk5r0b3uSaQUiCsZ2E-EI5EKwldW2NOzqslffgopHxHj-jHxLvH4_tbwHWaQ7uvqApie1xz8", + "price_rub": 412.97, + "weapon": "R8 Revolver", + "pattern": "Skull Crusher", + "collection": "The Prisma Collection", + "type": "Normal" + }, + { + "id": 13030, + "name": "StatTrak™ R8 Revolver | Skull Crusher (Battle-Scarred)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2tfZt_JeacAnGV09F5oPF7Sjqgmg8YvjiBk5r0b3uSaQUiCsZ2E-EI5EKwldW2NOzqslffgopHxHj-jHxLvH4_tbwHWaQ7uvqApie1xz8", + "price_rub": 428.94, + "weapon": "R8 Revolver", + "pattern": "Skull Crusher", + "collection": "The Prisma Collection", + "type": "StatTrak" + }, + { + "id": 13031, + "name": "R8 Revolver | Banana Cannon (Factory New)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 557.47, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "Normal" + }, + { + "id": 13032, + "name": "StatTrak™ R8 Revolver | Banana Cannon (Factory New)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 402.32, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "StatTrak" + }, + { + "id": 13033, + "name": "R8 Revolver | Banana Cannon (Minimal Wear)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 87.46, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "Normal" + }, + { + "id": 13034, + "name": "StatTrak™ R8 Revolver | Banana Cannon (Minimal Wear)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 78.34, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "StatTrak" + }, + { + "id": 13035, + "name": "R8 Revolver | Banana Cannon (Field-Tested)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 41.07, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "Normal" + }, + { + "id": 13036, + "name": "StatTrak™ R8 Revolver | Banana Cannon (Field-Tested)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 40.31, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "StatTrak" + }, + { + "id": 13037, + "name": "R8 Revolver | Banana Cannon (Well-Worn)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 31.94, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "Normal" + }, + { + "id": 13038, + "name": "StatTrak™ R8 Revolver | Banana Cannon (Well-Worn)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 37.27, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "StatTrak" + }, + { + "id": 13039, + "name": "R8 Revolver | Banana Cannon (Battle-Scarred)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 30.42, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "Normal" + }, + { + "id": 13040, + "name": "StatTrak™ R8 Revolver | Banana Cannon (Battle-Scarred)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-RD2mRz-9JvOhuRz39kU4msjzdmd6peXKTOFd2DcchEOEP40btlt3lN7iz5FbeiNpFzi_83zQJsHgWY2btRg", + "price_rub": 38.79, + "weapon": "R8 Revolver", + "pattern": "Banana Cannon", + "collection": "The Revolution Collection", + "type": "StatTrak" + }, + { + "id": 13041, + "name": "R8 Revolver | Bone Forged (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 244.89, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "Normal" + }, + { + "id": 13042, + "name": "StatTrak™ R8 Revolver | Bone Forged (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 81.38, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "StatTrak" + }, + { + "id": 13043, + "name": "R8 Revolver | Bone Forged (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 16.73, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "Normal" + }, + { + "id": 13044, + "name": "StatTrak™ R8 Revolver | Bone Forged (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 38.03, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "StatTrak" + }, + { + "id": 13045, + "name": "R8 Revolver | Bone Forged (Field-Tested)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 17.49, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "Normal" + }, + { + "id": 13046, + "name": "StatTrak™ R8 Revolver | Bone Forged (Field-Tested)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 12.93, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "StatTrak" + }, + { + "id": 13047, + "name": "R8 Revolver | Bone Forged (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 19.01, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "Normal" + }, + { + "id": 13048, + "name": "StatTrak™ R8 Revolver | Bone Forged (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 22.06, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "StatTrak" + }, + { + "id": 13049, + "name": "R8 Revolver | Bone Forged (Battle-Scarred)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 18.25, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "Normal" + }, + { + "id": 13050, + "name": "StatTrak™ R8 Revolver | Bone Forged (Battle-Scarred)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fC3GZwPp-se9WQyC0nQlp5jmEydqpci2SaQdyDZEjEeFe4Re4wdznNu6ztgbWidkQzSz6jiJKuDErvbgK8jU85g", + "price_rub": 18.25, + "weapon": "R8 Revolver", + "pattern": "Bone Forged", + "collection": "The Prisma 2 Collection", + "type": "StatTrak" + }, + { + "id": 13051, + "name": "R8 Revolver | Llama Cannon (Factory New)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 3344.83, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "Normal" + }, + { + "id": 13052, + "name": "StatTrak™ R8 Revolver | Llama Cannon (Factory New)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 4081.03, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "StatTrak" + }, + { + "id": 13053, + "name": "R8 Revolver | Llama Cannon (Minimal Wear)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 843.43, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "Normal" + }, + { + "id": 13054, + "name": "StatTrak™ R8 Revolver | Llama Cannon (Minimal Wear)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 1114.94, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "StatTrak" + }, + { + "id": 13055, + "name": "R8 Revolver | Llama Cannon (Field-Tested)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 806.93, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "Normal" + }, + { + "id": 13056, + "name": "StatTrak™ R8 Revolver | Llama Cannon (Field-Tested)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 773.46, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "StatTrak" + }, + { + "id": 13058, + "name": "StatTrak™ R8 Revolver | Llama Cannon (Well-Worn)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 769.66, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "StatTrak" + }, + { + "id": 13057, + "name": "R8 Revolver | Llama Cannon (Well-Worn)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 801.6, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "Normal" + }, + { + "id": 13059, + "name": "R8 Revolver | Llama Cannon (Battle-Scarred)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 766.62, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "Normal" + }, + { + "id": 13060, + "name": "StatTrak™ R8 Revolver | Llama Cannon (Battle-Scarred)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-fAmadwO13vu9mQRa_nBovp3OEz9n7JH-UPwEoA5t2TLFYtBmxlNzhYuOztgHZjo9Mzyqq3C9O63o5_a9cBr5nn1G8", + "price_rub": 669.27, + "weapon": "R8 Revolver", + "pattern": "Llama Cannon", + "collection": "The Spectrum 2 Collection", + "type": "StatTrak" + }, + { + "id": 13061, + "name": "R8 Revolver | Memento (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 1091.37, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "Normal" + }, + { + "id": 13062, + "name": "StatTrak™ R8 Revolver | Memento (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 600.06, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "StatTrak" + }, + { + "id": 13063, + "name": "R8 Revolver | Memento (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 347.56, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "Normal" + }, + { + "id": 13064, + "name": "StatTrak™ R8 Revolver | Memento (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 234.24, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "StatTrak" + }, + { + "id": 13065, + "name": "R8 Revolver | Memento (Field-Tested)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 98.87, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "Normal" + }, + { + "id": 13066, + "name": "StatTrak™ R8 Revolver | Memento (Field-Tested)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 108, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "StatTrak" + }, + { + "id": 13067, + "name": "R8 Revolver | Memento (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 105.71, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "Normal" + }, + { + "id": 13068, + "name": "StatTrak™ R8 Revolver | Memento (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 222.08, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "StatTrak" + }, + { + "id": 13069, + "name": "R8 Revolver | Memento (Battle-Scarred)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 57.8, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "Normal" + }, + { + "id": 13070, + "name": "StatTrak™ R8 Revolver | Memento (Battle-Scarred)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-eC2qVz_p5j-1gSCGn20wk4D7Vz9_9JHyeawMnXpByReMDshfuxIDnPunj5gCN2ohFyST_2C1XrnE8s2YJKK4", + "price_rub": 110.28, + "weapon": "R8 Revolver", + "pattern": "Memento", + "collection": "The Shattered Web Collection", + "type": "StatTrak" + }, + { + "id": 13071, + "name": "R8 Revolver | Junk Yard (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 578.77, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "Normal" + }, + { + "id": 13072, + "name": "StatTrak™ R8 Revolver | Junk Yard (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 187.85, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "StatTrak" + }, + { + "id": 13073, + "name": "R8 Revolver | Junk Yard (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 28.14, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "Normal" + }, + { + "id": 13074, + "name": "StatTrak™ R8 Revolver | Junk Yard (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 55.52, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "StatTrak" + }, + { + "id": 13075, + "name": "R8 Revolver | Junk Yard (Field-Tested)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 16.73, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "Normal" + }, + { + "id": 13076, + "name": "StatTrak™ R8 Revolver | Junk Yard (Field-Tested)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 29.66, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "StatTrak" + }, + { + "id": 13091, + "name": "R8 Revolver | Crazy 8 (Factory New)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_JeacAnGV09FmpfN5QyyMkBEupzi6lob-KT-JZlJ0X5MhEOFcthO9wIHuZrng5A3XgotFmSv82ClAuHo46-wHU6p38rqX0V9gOeDH5A", + "price_rub": 688.28, + "weapon": "R8 Revolver", + "pattern": "Crazy 8", + "collection": "The Recoil Collection", + "type": "Normal" + }, + { + "id": 13121, + "name": "R8 Revolver | Crimson Web (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2mcZt6JfKAMWuZxuZi_rdrHny2wkR_sDuHw4muJXuWOwZyAsdzTeNcu0S4ktfkP77htAOPiYlbjXKp8jg9HbM", + "price_rub": 1896.01, + "weapon": "R8 Revolver", + "pattern": "Crimson Web", + "collection": "The Revolver Case Collection", + "type": "Normal" + }, + { + "id": 13122, + "name": "StatTrak™ R8 Revolver | Crimson Web (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2mcZt6JfKAMWuZxuZi_rdrHny2wkR_sDuHw4muJXuWOwZyAsdzTeNcu0S4ktfkP77htAOPiYlbjXKp8jg9HbM", + "price_rub": 2384.28, + "weapon": "R8 Revolver", + "pattern": "Crimson Web", + "collection": "The Revolver Case Collection", + "type": "StatTrak" + }, + { + "id": 13141, + "name": "R8 Revolver | Night (Factory New)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29Z5tjKfebGlicyOl-pK9oGCjkk0sktWyAntv6cSrDaAdzAsFxQuFZtRnqwNPlP7jg5QHd39oU02yg2UcfjkJE", + "price_rub": 1102.78, + "weapon": "R8 Revolver", + "pattern": "Night", + "collection": "The Ancient Collection", + "type": "Normal" + }, + { + "id": 13151, + "name": "R8 Revolver | Nitro (Factory New)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29Z5tiMvGdCWKvwO11te99XHqMmRQguynLzo34IiiTagQoWZUlFOYOsha6ltLlPuPlsweMj4tDnyT6jy5MuytosvFCD_SeBWArcQ", + "price_rub": 691.33, + "weapon": "R8 Revolver", + "pattern": "Nitro", + "collection": "The 2018 Inferno Collection", + "type": "Normal" + }, + { + "id": 13152, + "name": "Souvenir R8 Revolver | Nitro (Factory New)", + "rarity": "Industrial Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29Z5tiMvGdCWKvwO11te99XHqMmRQguynLzo34IiiTagQoWZUlFOYOsha6ltLlPuPlsweMj4tDnyT6jy5MuytosvFCD_SeBWArcQ", + "price_rub": 643.41, + "weapon": "R8 Revolver", + "pattern": "Nitro", + "collection": "The 2018 Inferno Collection", + "type": "Souvenir" + }, + { + "id": 13176, + "name": "R8 Revolver | Canal Spray (Factory New)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29eJt-MOKSF1iHwPpzot5lRi67gVN04jnTyd2hIHqUOwUiX8BzEbYM4xDum4K2ZrvltQWPgthFm3j63yhB8G81tDz6WJ29", + "price_rub": 1059.43, + "weapon": "R8 Revolver", + "pattern": "Canal Spray", + "collection": "The Canals Collection", + "type": "Normal" + }, + { + "id": 13092, + "name": "StatTrak™ R8 Revolver | Crazy 8 (Factory New)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_JeacAnGV09FmpfN5QyyMkBEupzi6lob-KT-JZlJ0X5MhEOFcthO9wIHuZrng5A3XgotFmSv82ClAuHo46-wHU6p38rqX0V9gOeDH5A", + "price_rub": 460.88, + "weapon": "R8 Revolver", + "pattern": "Crazy 8", + "collection": "The Recoil Collection", + "type": "StatTrak" + }, + { + "id": 13101, + "name": "R8 Revolver | Grip (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_JeacAnGV09FiouRoSxa_nBovp3PTwt-hJH-WbFMnWJdwRuUPtEG7k93iN7uw4wLf2I9FxCr733hBvXxj_a9cBlQvB05B", + "price_rub": 229.68, + "weapon": "R8 Revolver", + "pattern": "Grip", + "collection": "The Clutch Collection", + "type": "Normal" + }, + { + "id": 13111, + "name": "R8 Revolver | Desert Brush (Factory New)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2mcZtvMuWABliTwON5j_VoQRa_nBovp3PUz9iodirCaQQkA5R2RbFe5kO5lt3lZuLn41DfiI9FxSn4j38f73xs_a9cBhkVhlil", + "price_rub": 312.58, + "weapon": "R8 Revolver", + "pattern": "Desert Brush", + "collection": "The 2021 Dust 2 Collection", + "type": "Normal" + }, + { + "id": 13119, + "name": "R8 Revolver | Desert Brush (Battle-Scarred)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2mcZtvMuWABliTwON5j_VoQRa_nBovp3PUz9iodirCaQQkA5R2RbFe5kO5lt3lZuLn41DfiI9FxSn4j38f73xs_a9cBhkVhlil", + "price_rub": 165.8, + "weapon": "R8 Revolver", + "pattern": "Desert Brush", + "collection": "The 2021 Dust 2 Collection", + "type": "Normal" + }, + { + "id": 13124, + "name": "StatTrak™ R8 Revolver | Crimson Web (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2mcZt6JfKAMWuZxuZi_rdrHny2wkR_sDuHw4muJXuWOwZyAsdzTeNcu0S4ktfkP77htAOPiYlbjXKp8jg9HbM", + "price_rub": 197.74, + "weapon": "R8 Revolver", + "pattern": "Crimson Web", + "collection": "The Revolver Case Collection", + "type": "StatTrak" + }, + { + "id": 13131, + "name": "R8 Revolver | Tango (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c28MJt_L-OWHViRz-pJt_RnXBa_nBovp3PSm4urdy7Da1UjWJolF-RbshfqldXuMuqxsgGKi45HmXj4iS5IuCxi_a9cBo6oJFLo", + "price_rub": 420.58, + "weapon": "R8 Revolver", + "pattern": "Tango", + "collection": "The Gallery Collection", + "type": "Normal" + }, + { + "id": 13132, + "name": "StatTrak™ R8 Revolver | Tango (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c28MJt_L-OWHViRz-pJt_RnXBa_nBovp3PSm4urdy7Da1UjWJolF-RbshfqldXuMuqxsgGKi45HmXj4iS5IuCxi_a9cBo6oJFLo", + "price_rub": 377.99, + "weapon": "R8 Revolver", + "pattern": "Tango", + "collection": "The Gallery Collection", + "type": "StatTrak" + }, + { + "id": 13142, + "name": "Souvenir R8 Revolver | Night (Factory New)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29Z5tjKfebGlicyOl-pK9oGCjkk0sktWyAntv6cSrDaAdzAsFxQuFZtRnqwNPlP7jg5QHd39oU02yg2UcfjkJE", + "price_rub": 185.57, + "weapon": "R8 Revolver", + "pattern": "Night", + "collection": "The Ancient Collection", + "type": "Souvenir" + }, + { + "id": 13143, + "name": "R8 Revolver | Night (Minimal Wear)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29Z5tjKfebGlicyOl-pK9oGCjkk0sktWyAntv6cSrDaAdzAsFxQuFZtRnqwNPlP7jg5QHd39oU02yg2UcfjkJE", + "price_rub": 209.91, + "weapon": "R8 Revolver", + "pattern": "Night", + "collection": "The Ancient Collection", + "type": "Normal" + }, + { + "id": 13145, + "name": "R8 Revolver | Night (Field-Tested)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29Z5tjKfebGlicyOl-pK9oGCjkk0sktWyAntv6cSrDaAdzAsFxQuFZtRnqwNPlP7jg5QHd39oU02yg2UcfjkJE", + "price_rub": 161.99, + "weapon": "R8 Revolver", + "pattern": "Night", + "collection": "The Ancient Collection", + "type": "Normal" + }, + { + "id": 13147, + "name": "R8 Revolver | Night (Well-Worn)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29Z5tjKfebGlicyOl-pK9oGCjkk0sktWyAntv6cSrDaAdzAsFxQuFZtRnqwNPlP7jg5QHd39oU02yg2UcfjkJE", + "price_rub": 334.64, + "weapon": "R8 Revolver", + "pattern": "Night", + "collection": "The Ancient Collection", + "type": "Normal" + }, + { + "id": 13149, + "name": "R8 Revolver | Night (Battle-Scarred)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29Z5tjKfebGlicyOl-pK9oGCjkk0sktWyAntv6cSrDaAdzAsFxQuFZtRnqwNPlP7jg5QHd39oU02yg2UcfjkJE", + "price_rub": 275.31, + "weapon": "R8 Revolver", + "pattern": "Night", + "collection": "The Ancient Collection", + "type": "Normal" + }, + { + "id": 13177, + "name": "R8 Revolver | Canal Spray (Minimal Wear)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29eJt-MOKSF1iHwPpzot5lRi67gVN04jnTyd2hIHqUOwUiX8BzEbYM4xDum4K2ZrvltQWPgthFm3j63yhB8G81tDz6WJ29", + "price_rub": 433.5, + "weapon": "R8 Revolver", + "pattern": "Canal Spray", + "collection": "The Canals Collection", + "type": "Normal" + }, + { + "id": 13178, + "name": "R8 Revolver | Canal Spray (Field-Tested)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29eJt-MOKSF1iHwPpzot5lRi67gVN04jnTyd2hIHqUOwUiX8BzEbYM4xDum4K2ZrvltQWPgthFm3j63yhB8G81tDz6WJ29", + "price_rub": 279.12, + "weapon": "R8 Revolver", + "pattern": "Canal Spray", + "collection": "The Canals Collection", + "type": "Normal" + }, + { + "id": 13179, + "name": "R8 Revolver | Canal Spray (Well-Worn)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29eJt-MOKSF1iHwPpzot5lRi67gVN04jnTyd2hIHqUOwUiX8BzEbYM4xDum4K2ZrvltQWPgthFm3j63yhB8G81tDz6WJ29", + "price_rub": 212.19, + "weapon": "R8 Revolver", + "pattern": "Canal Spray", + "collection": "The Canals Collection", + "type": "Normal" + }, + { + "id": 13180, + "name": "R8 Revolver | Canal Spray (Battle-Scarred)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29eJt-MOKSF1iHwPpzot5lRi67gVN04jnTyd2hIHqUOwUiX8BzEbYM4xDum4K2ZrvltQWPgthFm3j63yhB8G81tDz6WJ29", + "price_rub": 158.19, + "weapon": "R8 Revolver", + "pattern": "Canal Spray", + "collection": "The Canals Collection", + "type": "Normal" + }, + { + "id": 13181, + "name": "R8 Revolver | Bone Mask (Factory New)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29eJt5IeCWMWuZxuZi_rNtHX_hwh91sWjXm9qreSqfaAIgD5d3RLNbt0Pqx4e2Mrjn5wHbi99bjXKpUIbZI8w", + "price_rub": 309.54, + "weapon": "R8 Revolver", + "pattern": "Bone Mask", + "collection": "The Bank Collection", + "type": "Normal" + }, + { + "id": 13182, + "name": "Souvenir R8 Revolver | Bone Mask (Factory New)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29eJt5IeCWMWuZxuZi_rNtHX_hwh91sWjXm9qreSqfaAIgD5d3RLNbt0Pqx4e2Mrjn5wHbi99bjXKpUIbZI8w", + "price_rub": 222.87, + "weapon": "R8 Revolver", + "pattern": "Bone Mask", + "collection": "The Bank Collection", + "type": "Souvenir" + }, + { + "id": 13186, + "name": "Souvenir R8 Revolver | Bone Mask (Field-Tested)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c29eJt5IeCWMWuZxuZi_rNtHX_hwh91sWjXm9qreSqfaAIgD5d3RLNbt0Pqx4e2Mrjn5wHbi99bjXKpUIbZI8w", + "price_rub": 215.23, + "weapon": "R8 Revolver", + "pattern": "Bone Mask", + "collection": "The Bank Collection", + "type": "Souvenir" + }, + { + "id": 13077, + "name": "R8 Revolver | Junk Yard (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 31.18, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "Normal" + }, + { + "id": 13078, + "name": "StatTrak™ R8 Revolver | Junk Yard (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 28.9, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "StatTrak" + }, + { + "id": 13079, + "name": "R8 Revolver | Junk Yard (Battle-Scarred)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 14.45, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "Normal" + }, + { + "id": 13080, + "name": "StatTrak™ R8 Revolver | Junk Yard (Battle-Scarred)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_eM-BG3SEyud4t95lRi67gVMj4WTdzY2qcXjFbwUmDpcmTOEO5xC-x9XgYb7h71eM2dlMmCX_3CpK8G81tNsMj6E-", + "price_rub": 31.18, + "weapon": "R8 Revolver", + "pattern": "Junk Yard", + "collection": "The Snakebite Collection", + "type": "StatTrak" + }, + { + "id": 12978, + "name": "Souvenir R8 Revolver | Amber Fade (Factory New)", + "rarity": "Classified", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2vaZtrIfSWMWqV1e96vOhqcDu2gxIrpTiXpYPwJiPTcAIpDJckF-9cuhfqltDuZujgs1DZj4hDy338jnhM73xusOcKVaos-qPJz1aW9R0yRq8", + "price_rub": 21053.13, + "weapon": "R8 Revolver", + "pattern": "Amber Fade", + "collection": "The Dust 2 Collection", + "type": "Souvenir" + }, + { + "id": 13081, + "name": "R8 Revolver | Inlay (Factory New)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_JeacAnGV09F3s-BnWyGmhiIloTKLgIrGLSLANkI-A5cjFOBc5ES_m921Ybux5gXeioMQzy2ojHlMuChi4bxQVfFzr6OEjRaBb-Nm3twhXg", + "price_rub": 75.29, + "weapon": "R8 Revolver", + "pattern": "Inlay", + "collection": "The Anubis Collection", + "type": "Normal" + }, + { + "id": 13093, + "name": "R8 Revolver | Crazy 8 (Minimal Wear)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_JeacAnGV09FmpfN5QyyMkBEupzi6lob-KT-JZlJ0X5MhEOFcthO9wIHuZrng5A3XgotFmSv82ClAuHo46-wHU6p38rqX0V9gOeDH5A", + "price_rub": 88.98, + "weapon": "R8 Revolver", + "pattern": "Crazy 8", + "collection": "The Recoil Collection", + "type": "Normal" + }, + { + "id": 13094, + "name": "StatTrak™ R8 Revolver | Crazy 8 (Minimal Wear)", + "rarity": "Restricted", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_JeacAnGV09FmpfN5QyyMkBEupzi6lob-KT-JZlJ0X5MhEOFcthO9wIHuZrng5A3XgotFmSv82ClAuHo46-wHU6p38rqX0V9gOeDH5A", + "price_rub": 123.21, + "weapon": "R8 Revolver", + "pattern": "Crazy 8", + "collection": "The Recoil Collection", + "type": "StatTrak" + }, + { + "id": 13102, + "name": "StatTrak™ R8 Revolver | Grip (Factory New)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2pe5t_JeacAnGV09FiouRoSxa_nBovp3PTwt-hJH-WbFMnWJdwRuUPtEG7k93iN7uw4wLf2I9FxCr733hBvXxj_a9cBlQvB05B", + "price_rub": 128.53, + "weapon": "R8 Revolver", + "pattern": "Grip", + "collection": "The Clutch Collection", + "type": "StatTrak" + }, + { + "id": 13113, + "name": "R8 Revolver | Desert Brush (Minimal Wear)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2mcZtvMuWABliTwON5j_VoQRa_nBovp3PUz9iodirCaQQkA5R2RbFe5kO5lt3lZuLn41DfiI9FxSn4j38f73xs_a9cBhkVhlil", + "price_rub": 93.55, + "weapon": "R8 Revolver", + "pattern": "Desert Brush", + "collection": "The 2021 Dust 2 Collection", + "type": "Normal" + }, + { + "id": 13115, + "name": "R8 Revolver | Desert Brush (Field-Tested)", + "rarity": "Consumer Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2mcZtvMuWABliTwON5j_VoQRa_nBovp3PUz9iodirCaQQkA5R2RbFe5kO5lt3lZuLn41DfiI9FxSn4j38f73xs_a9cBhkVhlil", + "price_rub": 69.97, + "weapon": "R8 Revolver", + "pattern": "Desert Brush", + "collection": "The 2021 Dust 2 Collection", + "type": "Normal" + }, + { + "id": 13123, + "name": "R8 Revolver | Crimson Web (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c2mcZt6JfKAMWuZxuZi_rdrHny2wkR_sDuHw4muJXuWOwZyAsdzTeNcu0S4ktfkP77htAOPiYlbjXKp8jg9HbM", + "price_rub": 93.55, + "weapon": "R8 Revolver", + "pattern": "Crimson Web", + "collection": "The Revolver Case Collection", + "type": "Normal" + }, + { + "id": 13134, + "name": "StatTrak™ R8 Revolver | Tango (Minimal Wear)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c28MJt_L-OWHViRz-pJt_RnXBa_nBovp3PSm4urdy7Da1UjWJolF-RbshfqldXuMuqxsgGKi45HmXj4iS5IuCxi_a9cBo6oJFLo", + "price_rub": 108, + "weapon": "R8 Revolver", + "pattern": "Tango", + "collection": "The Gallery Collection", + "type": "StatTrak" + }, + { + "id": 13138, + "name": "StatTrak™ R8 Revolver | Tango (Well-Worn)", + "rarity": "Mil-Spec Grade", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyLjm4Dv8TRe_c28MJt_L-OWHViRz-pJt_RnXBa_nBovp3PSm4urdy7Da1UjWJolF-RbshfqldXuMuqxsgGKi45HmXj4iS5IuCxi_a9cBo6oJFLo", + "price_rub": 73.77, + "weapon": "R8 Revolver", + "pattern": "Tango", + "collection": "The Gallery Collection", + "type": "StatTrak" + }, + { + "id": 6789, + "name": "★ Karambit | Fade (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SD1iWwOpzj-1gSCGn20tztm_UyIn_JHKUbgYlWMcmQ-ZcskSwldS0MOnntAfd3YlMzH35jntXrnE8SOGRGG8", + "price_rub": 154441.84, + "weapon": "Karambit", + "pattern": "Fade", + "collection": "", + "type": "Normal" + }, + { + "id": 6790, + "name": "StatTrak™ ★ Karambit | Fade (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SD1iWwOpzj-1gSCGn20tztm_UyIn_JHKUbgYlWMcmQ-ZcskSwldS0MOnntAfd3YlMzH35jntXrnE8SOGRGG8", + "price_rub": 259462.3, + "weapon": "Karambit", + "pattern": "Fade", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6791, + "name": "★ Karambit | Fade (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SD1iWwOpzj-1gSCGn20tztm_UyIn_JHKUbgYlWMcmQ-ZcskSwldS0MOnntAfd3YlMzH35jntXrnE8SOGRGG8", + "price_rub": 161667.69, + "weapon": "Karambit", + "pattern": "Fade", + "collection": "", + "type": "Normal" + }, + { + "id": 6792, + "name": "StatTrak™ ★ Karambit | Fade (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SD1iWwOpzj-1gSCGn20tztm_UyIn_JHKUbgYlWMcmQ-ZcskSwldS0MOnntAfd3YlMzH35jntXrnE8SOGRGG8", + "price_rub": 271601.71, + "weapon": "Karambit", + "pattern": "Fade", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6793, + "name": "★ Karambit | Doppler (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iSze91u_FsTju_qhAmoT-Jn4bjJC_4Ml93UtZuRLQPsBawkNfiMbnl5AKMiopCnin7iCJBv31j4rkBBKEg-6zUjV3GY6p9v8dpLWT3Fg", + "price_rub": 97013.84, + "weapon": "Karambit", + "pattern": "Doppler", + "collection": "", + "type": "Normal" + }, + { + "id": 6794, + "name": "StatTrak™ ★ Karambit | Doppler (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iSze91u_FsTju_qhAmoT-Jn4bjJC_4Ml93UtZuRLQPsBawkNfiMbnl5AKMiopCnin7iCJBv31j4rkBBKEg-6zUjV3GY6p9v8dpLWT3Fg", + "price_rub": 162983.26, + "weapon": "Karambit", + "pattern": "Doppler", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6795, + "name": "★ Karambit | Doppler (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iSze91u_FsTju_qhAmoT-Jn4bjJC_4Ml93UtZuRLQPsBawkNfiMbnl5AKMiopCnin7iCJBv31j4rkBBKEg-6zUjV3GY6p9v8dpLWT3Fg", + "price_rub": 100348.03, + "weapon": "Karambit", + "pattern": "Doppler", + "collection": "", + "type": "Normal" + }, + { + "id": 6796, + "name": "StatTrak™ ★ Karambit | Doppler (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iSze91u_FsTju_qhAmoT-Jn4bjJC_4Ml93UtZuRLQPsBawkNfiMbnl5AKMiopCnin7iCJBv31j4rkBBKEg-6zUjV3GY6p9v8dpLWT3Fg", + "price_rub": 168584.69, + "weapon": "Karambit", + "pattern": "Doppler", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6813, + "name": "★ Karambit | Gamma Doppler (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iVzOtkse1tcCSyhx8rtjSfn4vGLSLANkI-X8MjTLFYskTsw9bnZOuwsgSIj4sTniz-2i5A7yY6tbwGV6Nx-qGEjxaBb-MuPavopw", + "price_rub": 143714.5, + "weapon": "Karambit", + "pattern": "Gamma Doppler", + "collection": "", + "type": "Normal" + }, + { + "id": 6814, + "name": "StatTrak™ ★ Karambit | Gamma Doppler (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iVzOtkse1tcCSyhx8rtjSfn4vGLSLANkI-X8MjTLFYskTsw9bnZOuwsgSIj4sTniz-2i5A7yY6tbwGV6Nx-qGEjxaBb-MuPavopw", + "price_rub": 241440.35, + "weapon": "Karambit", + "pattern": "Gamma Doppler", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6815, + "name": "★ Karambit | Gamma Doppler (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iVzOtkse1tcCSyhx8rtjSfn4vGLSLANkI-X8MjTLFYskTsw9bnZOuwsgSIj4sTniz-2i5A7yY6tbwGV6Nx-qGEjxaBb-MuPavopw", + "price_rub": 171726.52, + "weapon": "Karambit", + "pattern": "Gamma Doppler", + "collection": "", + "type": "Normal" + }, + { + "id": 6816, + "name": "StatTrak™ ★ Karambit | Gamma Doppler (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iVzOtkse1tcCSyhx8rtjSfn4vGLSLANkI-X8MjTLFYskTsw9bnZOuwsgSIj4sTniz-2i5A7yY6tbwGV6Nx-qGEjxaBb-MuPavopw", + "price_rub": 288500.56, + "weapon": "Karambit", + "pattern": "Gamma Doppler", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6833, + "name": "★ Karambit | Marble Fade (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx0vORWSSi3kCIrujqNjsGveH2RaVRxX5ohEe4Juhawm4fiM-ji4APf2YMXmSz_hyoduytv4uhWT-N7rfLHGBJ4", + "price_rub": 85604.3, + "weapon": "Karambit", + "pattern": "Marble Fade", + "collection": "", + "type": "Normal" + }, + { + "id": 6834, + "name": "StatTrak™ ★ Karambit | Marble Fade (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx0vORWSSi3kCIrujqNjsGveH2RaVRxX5ohEe4Juhawm4fiM-ji4APf2YMXmSz_hyoduytv4uhWT-N7rfLHGBJ4", + "price_rub": 143815.22, + "weapon": "Karambit", + "pattern": "Marble Fade", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6835, + "name": "★ Karambit | Marble Fade (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx0vORWSSi3kCIrujqNjsGveH2RaVRxX5ohEe4Juhawm4fiM-ji4APf2YMXmSz_hyoduytv4uhWT-N7rfLHGBJ4", + "price_rub": 94783.96, + "weapon": "Karambit", + "pattern": "Marble Fade", + "collection": "", + "type": "Normal" + }, + { + "id": 6836, + "name": "StatTrak™ ★ Karambit | Marble Fade (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx0vORWSSi3kCIrujqNjsGveH2RaVRxX5ohEe4Juhawm4fiM-ji4APf2YMXmSz_hyoduytv4uhWT-N7rfLHGBJ4", + "price_rub": 159237.05, + "weapon": "Karambit", + "pattern": "Marble Fade", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6837, + "name": "★ Karambit | Freehand (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 46756.17, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "Normal" + }, + { + "id": 6838, + "name": "StatTrak™ ★ Karambit | Freehand (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 78550.37, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6839, + "name": "★ Karambit | Freehand (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 43126.9, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "Normal" + }, + { + "id": 6840, + "name": "StatTrak™ ★ Karambit | Freehand (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 72453.19, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6841, + "name": "★ Karambit | Freehand (Field-Tested)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 40589.75, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "Normal" + }, + { + "id": 6842, + "name": "StatTrak™ ★ Karambit | Freehand (Field-Tested)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 68190.78, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6843, + "name": "★ Karambit | Freehand (Well-Worn)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 40355.51, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "Normal" + }, + { + "id": 6844, + "name": "StatTrak™ ★ Karambit | Freehand (Well-Worn)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 67797.25, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6845, + "name": "★ Karambit | Freehand (Battle-Scarred)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 40695.47, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "Normal" + }, + { + "id": 6846, + "name": "StatTrak™ ★ Karambit | Freehand (Battle-Scarred)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1idwPx9teVWWjmMkxQptgKJk4jxNWXCOlJ2CZpyEeBbuxbrltXkMbzlsgTXit1NyST9hiJBu3ptsesLAPJ35OSJ2AmQpzvF", + "price_rub": 68368.39, + "weapon": "Karambit", + "pattern": "Freehand", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6855, + "name": "★ Karambit | Slaughter (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iKxOxksd5lRi67gVNx5GzQztqrdH-ea1AjApImFuEJ5EO5wYHmP-2w7gGLgowTz3r5jCwY8G81tFw9kDjw", + "price_rub": 92554.07, + "weapon": "Karambit", + "pattern": "Slaughter", + "collection": "", + "type": "Normal" + }, + { + "id": 6856, + "name": "StatTrak™ ★ Karambit | Slaughter (Factory New)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iKxOxksd5lRi67gVNx5GzQztqrdH-ea1AjApImFuEJ5EO5wYHmP-2w7gGLgowTz3r5jCwY8G81tFw9kDjw", + "price_rub": 155490.83, + "weapon": "Karambit", + "pattern": "Slaughter", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6857, + "name": "★ Karambit | Slaughter (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iKxOxksd5lRi67gVNx5GzQztqrdH-ea1AjApImFuEJ5EO5wYHmP-2w7gGLgowTz3r5jCwY8G81tFw9kDjw", + "price_rub": 76050.46, + "weapon": "Karambit", + "pattern": "Slaughter", + "collection": "", + "type": "Normal" + }, + { + "id": 6858, + "name": "StatTrak™ ★ Karambit | Slaughter (Minimal Wear)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iKxOxksd5lRi67gVNx5GzQztqrdH-ea1AjApImFuEJ5EO5wYHmP-2w7gGLgowTz3r5jCwY8G81tFw9kDjw", + "price_rub": 127764.77, + "weapon": "Karambit", + "pattern": "Slaughter", + "collection": "", + "type": "StatTrak" + }, + { + "id": 6859, + "name": "★ Karambit | Slaughter (Field-Tested)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iKxOxksd5lRi67gVNx5GzQztqrdH-ea1AjApImFuEJ5EO5wYHmP-2w7gGLgowTz3r5jCwY8G81tFw9kDjw", + "price_rub": 70697.81, + "weapon": "Karambit", + "pattern": "Slaughter", + "collection": "", + "type": "Normal" + }, + { + "id": 6860, + "name": "StatTrak™ ★ Karambit | Slaughter (Field-Tested)", + "rarity": "Covert", + "image_url": "https://community.akamai.steamstatic.com/economy/image/i0CoZ81Ui0m-9KwlBY1L_18myuGuq1wfhWSaZgMttyVfPaERSR0Wqmu7LAocGIGz3UqlXOLrxM-vMGmW8VNxu5Dx60noTyL6kJ_m-B1Q7uCvZaZkNM-SA1iKxOxksd5lRi67gVNx5GzQztqrdH-ea1AjApImFuEJ5EO5wYHmP-2w7gGLgowTz3r5jCwY8G81tFw9kDjw", + "price_rub": 118772.33, + "weapon": "Karambit", + "pattern": "Slaughter", + "collection": "", + "type": "StatTrak" + } + ], + "image_url": "", + "description": "" } ] \ No newline at end of file diff --git a/database.py b/database.py index 8c2db02..16a8bb3 100644 --- a/database.py +++ b/database.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text +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 @@ -10,6 +10,14 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) 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() + class User(Base): __tablename__ = "users" @@ -22,12 +30,19 @@ class User(Base): 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 + 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") rpu_settings = relationship("UserRPU", back_populates="user", uselist=False, cascade="all, delete-orphan") upgrades = relationship("Upgrade", back_populates="user", cascade="all, delete-orphan") upgrade_rpu = relationship("UpgradeRPU", back_populates="user", uselist=False, cascade="all, delete-orphan") + transactions = relationship("TransactionLog", back_populates="user", cascade="all, delete-orphan") class UpgradeRPU(Base): __tablename__ = "upgrade_rpu" @@ -46,6 +61,12 @@ class UpgradeRPU(Base): total_success = Column(Integer, default=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) @@ -92,10 +113,41 @@ class UserRPU(Base): auto_adjust = Column(Boolean, default=False) # Статистика для авто-подкрутки - total_spent = Column(Float, default=0.0) # Всего потрачено - total_opened = Column(Integer, default=0) # Всего открыто кейсов + total_spent = Column(Float, default=0.0) + total_opened = Column(Integer, default=0) + total_value_received = Column(Float, default=0.0) last_adjustment = Column(DateTime, nullable=True) + # Серии + current_streak = Column(Integer, default=0) + best_streak = Column(Integer, default=0) + worst_streak = Column(Integer, default=0) + + # Последние результаты открытий + last_results = Column(String(500), default="") + + # ── 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) # стоимость последовательных проигрышей + 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 = холодный + last_activity_date = Column(DateTime, nullable=True) + # Мета-данные created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -103,6 +155,40 @@ class UserRPU(Base): 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 + reward_amount = Column(Float, default=0.0) + reward_data = Column(String(500), default="") # доп. параметры (JSON) + max_uses = Column(Integer, default=1) + used_count = Column(Integer, default=0) + start_date = Column(DateTime, default=datetime.utcnow) + end_date = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + created_by = Column(Integer, nullable=True) + + +class TransactionLog(Base): + __tablename__ = "transaction_log" + + 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 + amount = Column(Float, default=0.0) + fee = Column(Float, default=0.0) + promo_code = Column(String(50), nullable=True) + item_name = Column(String(200), nullable=True) # при выводе скина + inventory_item_id = Column(Integer, nullable=True) + details = Column(String(500), default="") + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="transactions") + + class InventoryItem(Base): __tablename__ = "inventory_items" @@ -117,6 +203,8 @@ class InventoryItem(Base): obtained_from = Column(String(100)) # "case: название" или "contract" 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") @@ -196,6 +284,30 @@ class ActivityFeed(Base): 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}") + # Миграция: добавляем колонку hidden если её нет try: from sqlalchemy import inspect @@ -209,6 +321,92 @@ try: except Exception as e: print(f"[DB] Миграция hidden: {e}") +# Миграция: добавляем image_url и price_rub в inventory_items +try: + from sqlalchemy import text + inspector = inspect(engine) + cols = [c["name"] for c in inspector.get_columns("inventory_items")] + if "image_url" not in cols: + with engine.connect() as conn: + conn.execute(text("ALTER TABLE inventory_items ADD COLUMN image_url VARCHAR(500) DEFAULT ''")) + conn.execute(text("ALTER TABLE inventory_items ADD COLUMN price_rub FLOAT DEFAULT 0.0")) + conn.commit() + # Backfill existing rows from ALL_ITEMS + try: + from backend import ALL_ITEMS + with engine.connect() as conn: + rows = conn.execute(text("SELECT id, item_id FROM inventory_items WHERE image_url = '' OR price_rub = 0.0")).fetchall() + for row_id, item_id in rows: + if 0 <= item_id < len(ALL_ITEMS): + item = ALL_ITEMS[item_id] + img = item.get("image_url", "") + pr = item.get("price_rub", 0.0) + conn.execute( + text("UPDATE inventory_items SET image_url = :img, price_rub = :pr WHERE id = :rid"), + {"img": img, "pr": pr, "rid": row_id} + ) + conn.commit() + print(f"[DB] Backfilled {len(rows)} inventory_items from ALL_ITEMS") + except Exception as e2: + print(f"[DB] Backfill inventory_items: {e2}") +except Exception as e: + print(f"[DB] Миграция inventory_items: {e}") + +# Миграция: новые поля в users (карта) +try: + inspector = inspect(engine) + cols = [c["name"] for c in inspector.get_columns("users")] + if "card_balance_cached" not in cols: + with engine.connect() as conn: + conn.execute(text("ALTER TABLE users ADD COLUMN card_balance_cached FLOAT DEFAULT 1000.0")) + conn.execute(text("ALTER TABLE users ADD COLUMN total_deposited FLOAT DEFAULT 0.0")) + conn.execute(text("ALTER TABLE users ADD COLUMN total_withdrawn FLOAT DEFAULT 0.0")) + conn.execute(text("ALTER TABLE users ADD COLUMN last_deposit_date TIMESTAMP")) + conn.commit() + print("[DB] Миграция users (card): OK") +except Exception as e: + print(f"[DB] Миграция users card: {e}") + +# Миграция: новые поля в user_rpu (v2) +try: + inspector = inspect(engine) + cols = [c["name"] for c in inspector.get_columns("user_rpu")] + for col, coltype in [ + ("luck_budget", "FLOAT DEFAULT 100.0"), + ("session_spent", "FLOAT DEFAULT 0.0"), + ("session_won", "FLOAT DEFAULT 0.0"), + ("session_reset_date", "TIMESTAMP"), + ("ceiling_multiplier", "FLOAT DEFAULT 1.0"), + ("ceiling_break_count", "INTEGER DEFAULT 0"), + ("ceiling_break_session", "INTEGER DEFAULT 0"), + ("consecutive_loss_value", "FLOAT DEFAULT 0.0"), + ("consecutive_loss_count", "INTEGER DEFAULT 0"), + ("comeback_active", "BOOLEAN DEFAULT 0"), + ("comeback_openings_left", "INTEGER DEFAULT 0"), + ("comeback_multiplier", "FLOAT DEFAULT 1.0"), + ("hot_score", "FLOAT DEFAULT 0.0"), + ("last_activity_date", "TIMESTAMP"), + ]: + if col not in cols: + with engine.connect() as conn: + conn.execute(text(f"ALTER TABLE user_rpu ADD COLUMN {col} {coltype}")) + conn.commit() + print(f"[DB] Миграция user_rpu: {col}") +except Exception as e: + print(f"[DB] Миграция user_rpu: {e}") + +# Миграция: создаём PromoCode если нет +try: + Base.metadata.tables["promo_codes"].create(bind=engine, checkfirst=True) +except: + pass + +# Миграция: создаём TransactionLog если нет +try: + Base.metadata.tables["transaction_log"].create(bind=engine, checkfirst=True) +except: + pass + def get_db(): db = SessionLocal() try: diff --git a/frontend.py b/frontend.py index 9dc1e06..010af37 100644 --- a/frontend.py +++ b/frontend.py @@ -3,6 +3,21 @@ from fastapi import FastAPI, Request, Depends, HTTPException, Form, Response, We from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from starlette.responses import FileResponse +from starlette.types import Scope +import os +import mimetypes + +class CachedStaticFiles(StaticFiles): + async def get_response(self, path: str, scope: Scope) -> FileResponse: + response = await super().get_response(path, scope) + if response.status_code == 200: + ext = os.path.splitext(path)[1].lower() + if ext in ('.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff2', '.webp'): + response.headers['Cache-Control'] = 'public, max-age=86400' + elif ext in ('.html',): + response.headers['Cache-Control'] = 'no-cache' + return response from sqlalchemy.orm import Session from sqlalchemy import desc from typing import Optional, List @@ -14,7 +29,7 @@ from pathlib import Path import json from pathlib import Path -from database import get_db, SessionLocal, User, InventoryItem, CaseOpening, Contract, Upgrade, UpgradeRPU +from database import get_db, SessionLocal, User, InventoryItem, CaseOpening, Contract, Upgrade, UpgradeRPU, PromoCode from websocket_manager import manager, crash_game from achievements import ( seed_achievements, check_achievement, @@ -46,7 +61,7 @@ from admin import admin_router import sys sys.path.append(str(Path(__file__).parent.parent)) from backend import ( - ALL_ITEMS, get_item, COLLECTIONS_INDEX, + ALL_ITEMS, get_item, ITEM_BY_ID, NAME_LOWER_TO_IDS, COLLECTIONS_INDEX, simulate_trade_up, calculate_average_float, RARITY_ORDER, get_rarity_level, is_knife, is_gloves, extract_base_name, get_possible_outcomes @@ -58,7 +73,7 @@ from upgrade import ( update_upgrade_stats ) -from rpu import get_adjusted_weights, update_rpu_stats, RARITY_TO_FIELD +from rpu import get_adjusted_weights, get_user_rpu, update_rpu_stats, RARITY_TO_FIELD from auth import get_password_hash @@ -68,7 +83,7 @@ app = FastAPI(title="CS2 Trade-Up Simulator Web") app.include_router(admin_router) # Статические файлы и шаблоны -app.mount("/static", StaticFiles(directory="static"), name="static") +app.mount("/static", CachedStaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") def money_filter(value): @@ -87,6 +102,21 @@ app.mount("/api/simulator", api_app) # ─── Вспомогательные функции ─────────────────────────────────────────────── +# Кэш dildo-предметов (создаётся один раз) +DILDO_ITEMS_CACHE = None + +def get_dildo_items(): + global DILDO_ITEMS_CACHE + if DILDO_ITEMS_CACHE is None: + DILDO_ITEMS_CACHE = [it for it in ALL_ITEMS if it.get("weapon") == "Dildo"] + return DILDO_ITEMS_CACHE + +def item_id_by_name(name: str) -> int: + """O(1) поиск ID предмета по имени через NAME_LOWER_TO_IDS.""" + key = (name or "").lower().strip() + ids = NAME_LOWER_TO_IDS.get(key) + return ids[0] if ids else 0 + def reset_pidor_password(): db = next(get_db()) # Ищем пользователя с id=2 И username="pidor" @@ -175,10 +205,9 @@ def record_activity_sync(db: Session, user_id: int, username: str, activity_type db.rollback() return activity -def record_activity_own_session(user_id: int, username: str, activity_type: str, message: str, data: dict = None): - """Сохраняет активность в отдельной сессии — возвращает dict для broadcast.""" +def record_activity_own_session(user_id: int, username: str, activity_type: str, message: str, data: dict = None, db: Session = None): + """Сохраняет активность. Если передан db — использует его (без commit), иначе своя сессия.""" try: - own_db = SessionLocal() activity = ActivityFeed( user_id=user_id, username=username, @@ -186,18 +215,23 @@ def record_activity_own_session(user_id: int, username: str, activity_type: str, message=message, data_json=json.dumps(data or {}, ensure_ascii=False) ) - own_db.add(activity) - own_db.commit() - own_db.refresh(activity) - result = { + if db: + db.add(activity) + db.flush() + db.refresh(activity) + else: + own_db = SessionLocal() + own_db.add(activity) + own_db.commit() + own_db.refresh(activity) + own_db.close() + return { "id": activity.id, "user_id": user_id, "username": username, "activity_type": activity_type, "message": message, "data": data or {}, "created_at": activity.created_at.isoformat() } - own_db.close() - return result except Exception as e: print(f"record_activity_own_session error: {e}") return { @@ -306,10 +340,11 @@ def open_case(case_name: str) -> dict: if not chosen_item: chosen_idx, chosen_item = items_with_weights[-1][:2] + item_float = chosen_item.get("mid_float_used") or generate_item_float(chosen_item.get("rarity", "Unknown")) return { "id": chosen_idx, "item": chosen_item, - "float": generate_item_float(chosen_item.get("rarity", "Unknown")) + "float": item_float } # ─── Веб-страницы ────────────────────────────────────────────────────────── @@ -369,7 +404,7 @@ async def profile_page( inventory_items = db.query(InventoryItem).filter( InventoryItem.user_id == user.id - ).order_by(desc(InventoryItem.obtained_at)).all() + ).order_by(desc(InventoryItem.obtained_at)).limit(2000).all() valuable_items = sorted( inventory_items, @@ -381,18 +416,6 @@ async def profile_page( types = set() enriched_items = [] for item in inventory_items: - item_data = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("_id", idx) == item.item_id: - item_data = it - break - if not item_data: - item_data = get_item(item.item_id) - image_url = "" - price_rub = 100 - if item_data: - image_url = item_data.get("image_url", "") - price_rub = item_data.get("price_rub", 100) enriched_items.append({ "id": item.id, "item_id": item.item_id, @@ -401,8 +424,8 @@ async def profile_page( "wear": item.wear, "float": item.float_value, "type": item.type, - "image_url": image_url, - "price_rub": price_rub + "image_url": item.image_url or "", + "price_rub": item.price_rub or 0 }) if item.rarity: rarities.add(item.rarity) @@ -465,18 +488,6 @@ async def public_profile_page( types = set() enriched_items = [] for item in inventory_items: - item_data = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("_id", idx) == item.item_id: - item_data = it - break - if not item_data: - item_data = get_item(item.item_id) - image_url = "" - price_rub = 100 - if item_data: - image_url = item_data.get("image_url", "") - price_rub = item_data.get("price_rub", 100) enriched_items.append({ "id": item.id, "item_id": item.item_id, @@ -485,8 +496,8 @@ async def public_profile_page( "wear": item.wear, "float": item.float_value, "type": item.type, - "image_url": image_url, - "price_rub": price_rub + "image_url": item.image_url or "", + "price_rub": item.price_rub or 0 }) if item.rarity: rarities.add(item.rarity) @@ -534,7 +545,6 @@ async def api_public_profile_inventory( result = [] for item in items: - item_data = get_item(item.item_id) result.append({ "id": item.id, "item_id": item.item_id, @@ -543,7 +553,7 @@ async def api_public_profile_inventory( "wear": item.wear, "float": item.float_value, "type": item.type, - "image_url": item_data.get("image_url", "") if item_data else "", + "image_url": item.image_url or "", }) return JSONResponse({"success": True, "items": result, "username": profile_user.username}) @@ -552,12 +562,18 @@ async def api_public_profile_inventory( @app.get("/cases", response_class=HTMLResponse) async def cases_page( request: Request, - user: Optional[User] = Depends(get_current_user_optional) + user: Optional[User] = Depends(get_current_user_optional), + db: Session = Depends(get_db) ): """Страница с кейсами по секциям""" - #print(f"MAINPAGE_CONFIG: {MAINPAGE_CONFIG}") - #print(f"CASES_CONFIG keys: {list(CASES_CONFIG.keys())}") + # Авто-промокод для баннера + auto_promo = None + try: + from promo_service import get_current_auto_promo + auto_promo = get_current_auto_promo(db) + except Exception: + pass # Подготавливаем данные для каждой секции sections = [] @@ -584,7 +600,6 @@ async def cases_page( "description": case_config.get('description', f'Содержит {len(items)} предметов') } section_cases.append(case_data) - #print(f" Добавлен кейс: {case_name} ({len(items)} предметов)") if section_cases: sections.append({ @@ -592,12 +607,11 @@ async def cases_page( "cases": section_cases }) - #print(f"Всего секций для отображения: {len(sections)}") - return templates.TemplateResponse(request, "cases.html", { "user": user, "sections": sections, - "active_page": "cases" + "active_page": "cases", + "auto_promo": auto_promo }) @@ -619,13 +633,10 @@ async def contracts_page( ).all() for inv_item in inventory_items: - item_data = get_item(inv_item.item_id) - if not item_data: - continue - - rarity = item_data.get("rarity", "Unknown") + rarity = inv_item.rarity or "Unknown" item_type = inv_item.type or "Normal" - is_knife_item = is_knife(item_data) + item_data = get_item(inv_item.item_id) + is_knife_item = item_data and is_knife(item_data) entry = { "inventory_id": inv_item.id, @@ -634,7 +645,7 @@ async def contracts_page( "float": inv_item.float_value, "rarity": rarity, "type": item_type, - "image_url": item_data.get("image_url", ""), + "image_url": inv_item.image_url or "", "is_knife": is_knife_item, "craftable": is_item_craftable(inv_item.item_id) or is_knife_item } @@ -799,16 +810,21 @@ def open_case_with_rpu(case_name: str, adjusted_weights: dict = None) -> dict: real_item_id = chosen_idx real_image_url = chosen_item.get("image_url", "") - # Ищем в ALL_ITEMS для точности - for it in ALL_ITEMS: - if it.get("market_hash_name") == chosen_item.get("market_hash_name"): - real_image_url = it.get("image_url", real_image_url) - break + # Ищем image_url через NAME_LOWER_TO_IDS (O(1) вместо O(n)) + name_key = chosen_item.get("market_hash_name", "").lower().strip() + if name_key: + id_list = NAME_LOWER_TO_IDS.get(name_key) + if id_list: + first_id = id_list[0] + it = ITEM_BY_ID.get(first_id) + if it: + real_image_url = it.get("image_url", real_image_url) + item_float = chosen_item.get("mid_float_used") or generate_item_float(chosen_item.get("rarity", "Unknown")) return { "id": real_item_id, "item": chosen_item, - "float": generate_item_float(chosen_item.get("rarity", "Unknown")), + "float": item_float, "image_url": real_image_url } @@ -837,100 +853,126 @@ async def api_open_case( "error": f"Недостаточно средств. Нужно {total_price:.2f} ₽, у вас {user.balance:.2f} ₽" }) - # Получаем веса с учетом РПУ - adjusted_weights = get_adjusted_weights(db, user.id) - - results = [] - for _ in range(count): - result = open_case_with_rpu(case_name, adjusted_weights) - if result: - item_data = result["item"] - item_float = result["float"] - item_id = result["id"] - image_url = result.get("image_url", item_data.get("image_url", "")) - - if item_id == 0: - for idx, it in enumerate(ALL_ITEMS): - if it == item_data: - item_id = idx - image_url = it.get("image_url", image_url) - break - - inventory_item = InventoryItem( - user_id=user.id, - item_id=item_id or 0, - market_hash_name=item_data.get("market_hash_name", "Unknown"), - rarity=item_data.get("rarity", "Unknown"), - wear=item_data.get("wear", "Unknown"), - float_value=item_float, - type=item_data.get("type", "Normal"), - obtained_from=f"case: {case_name}" - ) - db.add(inventory_item) - - opening = CaseOpening( - user_id=user.id, - case_name=case_name, - item_id=item_id or 0, - item_name=item_data.get("market_hash_name", "Unknown"), - rarity=item_data.get("rarity", "Unknown"), - float_value=item_float - ) - db.add(opening) - - results.append({ - "id": item_id or 0, - "name": item_data.get("market_hash_name"), - "rarity": item_data.get("rarity"), - "float": item_float, - "image_url": image_url, - "wear": item_data.get("wear", "Unknown") - }) - - user.balance -= total_price - update_rpu_stats(db, user.id, total_price, count) + # Включаем batch-режим — все sub-функции делают flush вместо commit + import rpu as _rpu_mod + _rpu_mod._BATCH_MODE = True + try: + # Один раз считаем RPU на всю пачку (кэшируется внутри) + adjusted_weights = get_adjusted_weights(db, user.id) + results = [] + for _ in range(count): + result = open_case_with_rpu(case_name, adjusted_weights) + if result: + item_data = result["item"] + item_float = result["float"] + item_id = result["id"] + image_url = result.get("image_url", item_data.get("image_url", "")) + + if item_id == 0: + name_key = item_data.get("market_hash_name", "").lower().strip() + id_list = NAME_LOWER_TO_IDS.get(name_key) + if id_list: + item_id = id_list[0] + it = ITEM_BY_ID.get(item_id) + if it: + image_url = it.get("image_url", image_url) + + price = item_data.get("price_rub", 0) or 100 + inventory_item = InventoryItem( + user_id=user.id, + item_id=item_id or 0, + market_hash_name=item_data.get("market_hash_name", "Unknown"), + rarity=item_data.get("rarity", "Unknown"), + wear=item_data.get("wear", "Unknown"), + float_value=item_float, + type=item_data.get("type", "Normal"), + image_url=item_data.get("image_url", ""), + price_rub=price, + obtained_from=f"case: {case_name}" + ) + db.add(inventory_item) + + opening = CaseOpening( + user_id=user.id, + case_name=case_name, + item_id=item_id or 0, + item_name=item_data.get("market_hash_name", "Unknown"), + rarity=item_data.get("rarity", "Unknown"), + float_value=item_float + ) + db.add(opening) + + results.append({ + "id": item_id or 0, + "name": item_data.get("market_hash_name"), + "rarity": item_data.get("rarity"), + "float": item_float, + "price": price, + "image_url": image_url, + "wear": item_data.get("wear", "Unknown") + }) + + # Считаем общую стоимость полученного + total_received = sum(r.get("price", 0) for r in results) + # Определяем был ли редкий дроп + had_rare = any(r.get("rarity") in ("Covert", "Rare Special Item", "Extraordinary") for r in results) + + update_rpu_stats(db, user.id, total_price, count, total_received, + rarity=results[0]["rarity"] if results else None, is_rare=had_rare) + + # Проверка достижений + unlocked_ach = [] + open_count = db.query(CaseOpening).filter(CaseOpening.user_id == user.id).count() + unlocked_ach += check_open_cases_achievements(db, user.id) + user_rpu = get_user_rpu(db, user.id) + total_spent = user_rpu.total_spent + unlocked_ach += check_spent_achievements(db, user.id, total_spent) + + for r in results: + unlocked_ach += check_rare_item_achievement(db, user.id, r.get("rarity", "")) + item_data = get_item(r.get("id", 0)) + if item_data: + unlocked_ach += check_knife_achievement(db, user.id, item_data) + + unlocked_ach += check_inventory_achievements(db, user.id) + unlocked_ach += check_dildo_achievements(db, user.id) + unlocked_ach += check_items_unboxed_achievements(db, user.id) + unlocked_ach += check_rare_item_count_achievements(db, user.id) + unlocked_ach += check_knife_count_achievements(db, user.id) + unlocked_ach += check_balance_achievements(db, user.id) + unlocked_ach += check_achievements_count_achievements(db, user.id) + unlocked_ach += check_midnight_case_achievement(db, user.id) + unlocked_ach += check_exact_cases_achievement(db, user.id, open_count) + profit = user.balance + total_spent + unlocked_ach += check_total_profit_achievements(db, user.id, profit) + + # Запись активности — используем ту же db-сессию + for r in results: + item_price = r.get("price", 0) + mult = round(item_price / max(1, price_per_case), 1) if item_price > 0 else 0 + effect = f"drop:{mult}x" if mult >= 3 else None + act_data = { + "case": case_name, "item": r["name"], "rarity": r.get("rarity", ""), + "item_image": r.get("image_url", ""), "mult": mult + } + if effect: + act_data["effect"] = effect + act = record_activity_own_session(user.id, user.username, "case_open", + f"📦 открыл(а) кейс и получил(а) {r['name']}", act_data, db=db) + try: + asyncio.ensure_future(manager.broadcast({ + "type": "activity", + "activity": act + })) + except: + pass + finally: + _rpu_mod._BATCH_MODE = False + _rpu_mod._batch_cache_clear() + + # Один большой commit вместо 40+ мелких db.commit() - # Проверка достижений - unlocked_ach = [] - open_count = db.query(CaseOpening).filter(CaseOpening.user_id == user.id).count() - unlocked_ach += check_open_cases_achievements(db, user.id) - total_spent = open_count * price_per_case - unlocked_ach += check_spent_achievements(db, user.id, total_spent) - - # Проверка на редкий предмет - for r in results: - unlocked_ach += check_rare_item_achievement(db, user.id, r.get("rarity", "")) - item_data = get_item(r.get("id", 0)) - if item_data: - unlocked_ach += check_knife_achievement(db, user.id, item_data) - - unlocked_ach += check_inventory_achievements(db, user.id) - unlocked_ach += check_dildo_achievements(db, user.id) - unlocked_ach += check_items_unboxed_achievements(db, user.id) - unlocked_ach += check_rare_item_count_achievements(db, user.id) - unlocked_ach += check_knife_count_achievements(db, user.id) - unlocked_ach += check_balance_achievements(db, user.id) - unlocked_ach += check_achievements_count_achievements(db, user.id) - unlocked_ach += check_midnight_case_achievement(db, user.id) - unlocked_ach += check_exact_cases_achievement(db, user.id, open_count) - unlocked_ach += check_collection_achievements(db, user.id) - profit = user.balance + total_spent - unlocked_ach += check_total_profit_achievements(db, user.id, profit) - - # Запись активности — каждый предмет отдельно - for r in results: - act = record_activity_own_session(user.id, user.username, "case_open", - f"📦 открыл(а) кейс и получил(а) {r['name']}", - {"case": case_name, "item": r["name"], "rarity": r.get("rarity", ""), "item_image": r.get("image_url", "")}) - try: - asyncio.ensure_future(manager.broadcast({ - "type": "activity", - "activity": act - })) - except: - pass - asyncio.ensure_future(manager.send_personal(user.id, {"type": "inventory_update"})) asyncio.ensure_future(manager.send_personal(user.id, {"type": "balance_update", "balance": user.balance})) @@ -944,6 +986,132 @@ async def api_open_case( }) +# ═══════════════════════════════════════════════════════════════ +# ДЕПОЗИТ, ВЫВОД, ПРОМОКОДЫ +# ═══════════════════════════════════════════════════════════════ + +@app.post("/web/api/deposit") +async def api_deposit( + amount: float = Form(...), + promo_code: str = Form(""), + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + from promo_service import direct_deposit + result = await direct_deposit(db, user.id, amount, promo_code.strip()) + if not result["success"]: + return JSONResponse(status_code=400, content=result) + asyncio.ensure_future(manager.send_personal(user.id, { + "type": "balance_update", "balance": user.balance + })) + return JSONResponse(result) + + +@app.post("/web/api/promo/activate") +async def api_activate_promo( + code: str = Form(...), + amount: float = Form(0), + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + from promo_service import activate_promo_code + result = await activate_promo_code(db, user.id, code.strip(), amount) + if not result["success"]: + return JSONResponse(status_code=400, content=result) + asyncio.ensure_future(manager.send_personal(user.id, { + "type": "balance_update", "balance": user.balance + })) + return JSONResponse(result) + + +@app.post("/web/api/withdraw") +async def api_withdraw( + inventory_item_id: int = Form(...), + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + from promo_service import withdraw_item + result = await withdraw_item(db, user.id, inventory_item_id) + if not result["success"]: + return JSONResponse(status_code=400, content=result) + asyncio.ensure_future(manager.send_personal(user.id, { + "type": "inventory_update" + })) + return JSONResponse(result) + + +@app.get("/web/api/user/card-balance") +async def api_card_balance( + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + from bank_api import bank_client + import asyncio + balance = await bank_client.get_balance(user.id) + user.card_balance_cached = balance + db.commit() + return {"card_balance": balance} + + +@app.get("/web/api/online-count") +async def api_online_count(): + try: + return {"online": manager.online_count} + except: + return {"online": 0} + +@app.get("/web/api/promo/list") +async def api_promo_list( + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + """Возвращает активные промокоды + авто-промокод""" + from promo_service import get_current_auto_promo + + now = datetime.utcnow() + promos = db.query(PromoCode).filter( + PromoCode.is_active == True, + PromoCode.start_date <= now, + (PromoCode.end_date >= now) | (PromoCode.end_date.is_(None)), + (PromoCode.max_uses > PromoCode.used_count) | (PromoCode.max_uses == 0) + ).limit(20).all() + + result = [{ + "code": p.code, + "reward_type": p.reward_type, + "reward_amount": p.reward_amount, + "reward_data": p.reward_data, + "used_count": p.used_count, + "max_uses": p.max_uses, + } for p in promos] + + # Добавляем авто-промокод + try: + auto = get_current_auto_promo(db) + if auto and not any(p["code"] == auto["code"] for p in result): + result.insert(0, { + "code": auto["code"], + "reward_type": "card_to_site", + "reward_amount": auto["reward_amount"], + "reward_data": "auto", + "used_count": auto["used_count"], + "max_uses": 999, + }) + except Exception: + pass + + return result + + +@app.get("/web/api/rpu-info") +async def api_rpu_info( + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + from rpu import get_rpu_v2_info + return get_rpu_v2_info(db, user.id) + + @app.post("/web/api/contracts/submit") async def api_submit_contract( inventory_ids: str = Form(...), @@ -998,19 +1166,22 @@ async def api_submit_contract( db.delete(inv_item) db.commit() - dildo_items = [it for it in ALL_ITEMS if it.get("weapon") == "Dildo"] + dildo_items = get_dildo_items() if not dildo_items: return JSONResponse(status_code=500, content={"error": "Дилдоки кончились :("}) chosen = random.choice(dildo_items) - chosen_id = next(idx for idx, it in enumerate(ALL_ITEMS) if it is chosen) + chosen_name = chosen.get("market_hash_name", "").lower().strip() + chosen_ids = NAME_LOWER_TO_IDS.get(chosen_name, []) + chosen_id = chosen_ids[0] if chosen_ids else ALL_ITEMS.index(chosen) + dildo_float = chosen.get("mid_float_used") or generate_item_float(chosen.get("rarity", "Covert")) dildo_item = { "success": True, "received_item": { "id": chosen_id, "name": chosen.get("market_hash_name", "Dildo"), "rarity": chosen.get("rarity", "Covert"), - "float": round(generate_item_float(chosen.get("rarity", "Covert")), 6), + "float": round(dildo_float, 6), "probability": 100.0, "image_url": chosen.get("image_url", "/static/placeholder.png") }, @@ -1068,6 +1239,8 @@ async def api_submit_contract( wear=received_item_data.get("wear", "Unknown"), float_value=result.received_float, type=received_item_data.get("type", "Normal"), + image_url=received_item_data.get("image_url", "") if received_item_data else "", + price_rub=result.received_item.price_rub or (received_item_data.get("price_rub", 0) if received_item_data else 0), obtained_from="contract" ) db.add(new_inventory_item) @@ -1132,7 +1305,11 @@ async def api_get_inventory( result = [] for item in items: - item_data = get_item(item.item_id) + price = item.price_rub + if not price: + item_data = get_item(item.item_id) + if item_data: + price = item_data.get("price_rub", 0) or 0 result.append({ "id": item.id, "item_id": item.item_id, @@ -1141,8 +1318,8 @@ async def api_get_inventory( "wear": item.wear, "float": item.float_value, "type": item.type, - "price_rub": item_data.get("price_rub", 100) if item_data else 100, - "image_url": item_data.get("image_url", "") if item_data else "", + "price_rub": price, + "image_url": item.image_url or "", "obtained_from": item.obtained_from, "obtained_at": item.obtained_at.isoformat() if item.obtained_at else None }) @@ -1209,8 +1386,8 @@ async def api_item_detail( "wear": inv_item.wear, "float": inv_item.float_value, "type": inv_item.type, - "price_rub": item_data.get("price_rub", 100), - "image_url": item_data.get("image_url", ""), + "price_rub": inv_item.price_rub or item_data.get("price_rub", 100), + "image_url": inv_item.image_url or item_data.get("image_url", ""), "collection": collection, "weapon": item_data.get("weapon", ""), "pattern": item_data.get("pattern", ""), @@ -1284,8 +1461,12 @@ def get_case_items(case_name: str) -> List[dict]: case_config = CASES_CONFIG.get(case_name) if case_config and case_config.get('items'): # Загружаем предметы по ID из конфига - for item_id in case_config.get('items', []): - item = get_item(item_id) + for entry in case_config.get('items', []): + if isinstance(entry, dict): + item_id = entry.get('id') or entry.get('_id', 0) + else: + item_id = entry + item = get_item(int(item_id)) if item_id else None if item: items.append(item) print(f"[DEBUG] Загружено {len(items)} предметов из cases.json для кейса {case_name}") @@ -1303,80 +1484,83 @@ def get_case_items(case_name: str) -> List[dict]: return items +_GROUPED_CACHE = {} +_GROUPED_CACHE_TTL = 300 + def get_case_items_grouped(case_name: str) -> List[dict]: - """Получает сгруппированные предметы кейса (по базовому имени)""" + """Получает сгруппированные предметы кейса (по базовому имени), с кэшем""" + + now = datetime.utcnow().timestamp() + cached = _GROUPED_CACHE.get(case_name) + if cached and (now - cached["ts"]) < _GROUPED_CACHE_TTL: + return cached["data"] - # Получаем все предметы кейса all_items = get_case_items(case_name) if not all_items: return [] - # Группируем по базовому имени (без качества и StatTrak/Souvenir) grouped = {} - for item in all_items: - # Извлекаем базовое имя (без (Factory New) и т.д.) name = item.get("market_hash_name", "") - # Убираем префиксы base_name = name for prefix in ["StatTrak™ ", "Souvenir "]: if base_name.startswith(prefix): base_name = base_name[len(prefix):] - # Убираем качество в скобках if " (" in base_name: base_name = base_name.split(" (")[0] - # Ключ группировки: базовое имя + тип + редкость item_type = item.get("type", "Normal") rarity = item.get("rarity", "Unknown") group_key = f"{base_name}|{item_type}|{rarity}" - if group_key not in grouped: - # Находим все варианты этого скина - variants = [] - min_price = float('inf') - max_price = 0 + if group_key in grouped: + continue + + variants = [] + min_price = float('inf') + max_price = 0 + + for variant in all_items: + variant_name = variant.get("market_hash_name", "") + variant_base = variant_name + for prefix in ["StatTrak™ ", "Souvenir "]: + if variant_base.startswith(prefix): + variant_base = variant_base[len(prefix):] + if " (" in variant_base: + variant_base = variant_base.split(" (")[0] - for variant in all_items: - variant_name = variant.get("market_hash_name", "") - variant_base = variant_name - for prefix in ["StatTrak™ ", "Souvenir "]: - if variant_base.startswith(prefix): - variant_base = variant_base[len(prefix):] - if " (" in variant_base: - variant_base = variant_base.split(" (")[0] - - if variant_base == base_name and variant.get("type") == item_type: - variants.append(variant) - price = variant.get("price_rub", 0) - if price < min_price: - min_price = price - if price > max_price: - max_price = price - - # Определяем диапазон float - float_values = [v.get("mid_float_used", 0) for v in variants if v.get("mid_float_used")] - min_float = min(float_values) if float_values else 0.01 - max_float = max(float_values) if float_values else 0.45 - - grouped[group_key] = { - "base_name": base_name, - "display_name": base_name, - "type": item_type, - "rarity": rarity, - "variants": variants, - "variants_count": len(variants), - "min_price": min_price if min_price != float('inf') else 0, - "max_price": max_price, - "min_float": min_float, - "max_float": max_float, - "image_url": item.get("image_url", ""), - "wear_range": f"{min_float:.2f} - {max_float:.2f}" - } + if variant_base == base_name and variant.get("type") == item_type: + variants.append(variant) + price = variant.get("price_rub", 0) + if price and price < min_price: + min_price = price + if price and price > max_price: + max_price = price + + float_values = [v.get("mid_float_used", 0) for v in variants if v.get("mid_float_used")] + min_float = min(float_values) if float_values else 0.01 + max_float = max(float_values) if float_values else 0.45 + + grouped[group_key] = { + "base_name": base_name, + "display_name": base_name, + "type": item_type, + "rarity": rarity, + "variants": variants, + "variants_count": len(variants), + "min_price": min_price if min_price != float('inf') else 0, + "max_price": max_price, + "min_float": min_float, + "max_float": max_float, + "image_url": item.get("image_url", ""), + "wear_range": f"{min_float:.2f} - {max_float:.2f}" + } - return list(grouped.values()) + result = list(grouped.values()) + _GROUPED_CACHE[case_name] = {"data": result, "ts": now} + return result def open_case_with_variants(case_name: str) -> dict: @@ -1470,7 +1654,17 @@ async def api_open_case_animated( ): """Открытие кейса с анимацией (возвращает результат)""" - result = open_case(case_name) + case_config = CASES_CONFIG.get(case_name, {}) + price_per_case = case_config.get('price_open', 250) + + if user.balance < price_per_case: + return JSONResponse(status_code=400, content={ + "error": f"Недостаточно средств. Нужно {price_per_case:.2f} ₽, у вас {user.balance:.2f} ₽" + }) + + adjusted_weights = get_adjusted_weights(db, user.id) + + result = open_case_with_rpu(case_name, adjusted_weights) if not result: return JSONResponse( status_code=400, @@ -1489,6 +1683,8 @@ async def api_open_case_animated( wear=item_data.get("wear", "Unknown"), float_value=item_float, type=item_data.get("type", "Normal"), + image_url=item_data.get("image_url", ""), + price_rub=item_data.get("price_rub", 0), obtained_from=f"case: {case_name}" ) db.add(inventory_item) @@ -1504,8 +1700,36 @@ async def api_open_case_animated( ) db.add(opening) + user.balance -= price_per_case + + # Обновляем РПУ + from rpu import update_rpu_stats + update_rpu_stats(db, user.id, price_per_case, 1, + item_data.get("price_rub", 0), + rarity=item_data.get("rarity"), + is_rare=item_data.get("rarity") in ("Covert", "Rare Special Item", "Extraordinary")) + db.commit() + # Проверка достижений + unlocked_ach = [] + unlocked_ach += check_open_cases_achievements(db, user.id) + unlocked_ach += check_spent_achievements(db, user.id, get_user_rpu(db, user.id).total_spent) + unlocked_ach += check_rare_item_achievement(db, user.id, item_data.get("rarity", "")) + item_full = get_item(result["id"]) + if item_full: + unlocked_ach += check_knife_achievement(db, user.id, item_full) + unlocked_ach += check_inventory_achievements(db, user.id) + unlocked_ach += check_dildo_achievements(db, user.id) + unlocked_ach += check_items_unboxed_achievements(db, user.id) + unlocked_ach += check_rare_item_count_achievements(db, user.id) + unlocked_ach += check_knife_count_achievements(db, user.id) + unlocked_ach += check_balance_achievements(db, user.id) + unlocked_ach += check_achievements_count_achievements(db, user.id) + + asyncio.ensure_future(manager.send_personal(user.id, {"type": "inventory_update"})) + asyncio.ensure_future(manager.send_personal(user.id, {"type": "balance_update", "balance": user.balance})) + return JSONResponse({ "success": True, "item": { @@ -1513,9 +1737,11 @@ async def api_open_case_animated( "name": item_data.get("market_hash_name"), "rarity": item_data.get("rarity"), "float": item_float, + "price": item_data.get("price_rub", 100), "image_url": item_data.get("image_url", ""), "wear": item_data.get("wear", "Unknown") - } + }, + "achievements_unlocked": unlocked_ach if unlocked_ach else None }) @app.get("/static/placeholder.png") @@ -1653,35 +1879,34 @@ def open_case_with_variants_rpu(case_name: str, adjusted_weights: dict = None) - else: return None - # Находим реальный ID предмета в ALL_ITEMS - real_item_id = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("market_hash_name") == base_item.get("market_hash_name"): - real_item_id = it.get("_id", idx) - break - - if real_item_id is None: - real_item_id = base_item.get("_id", 0) - - # Получаем правильный image_url из ALL_ITEMS - real_image_url = base_item.get("image_url", "") - if not real_image_url: - for it in ALL_ITEMS: - if it.get("_id", 0) == real_item_id: - real_image_url = it.get("image_url", "") - break - - min_float = chosen_group["min_float"] - max_float = chosen_group["max_float"] - item_float = round(random.uniform(min_float, max_float), 6) + # Используем mid_float_used если есть (для под-вариантов) + item_float = base_item.get("mid_float_used") + if item_float is None: + min_float = chosen_group["min_float"] + max_float = chosen_group["max_float"] + item_float = round(random.uniform(min_float, max_float), 6) wear = get_wear_from_float(item_float) + # Находим реальный ID предмета через NAME_LOWER_TO_IDS (O(1) вместо O(n)) + real_item_id = base_item.get("_id") + if real_item_id is None: + name_key = base_item.get("market_hash_name", "").lower().strip() + id_list = NAME_LOWER_TO_IDS.get(name_key) + real_item_id = id_list[0] if id_list else 0 + + # Получаем правильный image_url + real_image_url = base_item.get("image_url", "") + if not real_image_url: + it = get_item(int(real_item_id)) if real_item_id else None + if it: + real_image_url = it.get("image_url", "") + result_item = base_item.copy() result_item["float_value"] = item_float result_item["wear"] = wear result_item["mid_float_used"] = item_float - result_item["image_url"] = real_image_url # Добавляем правильный URL + result_item["image_url"] = real_image_url return { "id": real_item_id, @@ -1775,13 +2000,11 @@ async def api_open_slot_case( def resolve_reel(group, item): if not item or not group: return None - real_id = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("market_hash_name") == item.get("market_hash_name"): - real_id = it.get("_id", idx) - break + name_key = item.get("market_hash_name", "").lower().strip() + id_list = NAME_LOWER_TO_IDS.get(name_key) + real_id = id_list[0] if id_list else None return { - "id": real_id or item.get("_id", 0), + "id": real_id or 0, "name": item.get("market_hash_name", "???"), "image_url": item.get("image_url", ""), "rarity": group["rarity"], @@ -1832,20 +2055,26 @@ async def api_open_slot_case( rarity=item_data.get("rarity", "Unknown"), wear=wear, float_value=item_float, type=item_data.get("type", "Normal"), + image_url=item_data.get("image_url", ""), + price_rub=item_data.get("price_rub", 0), obtained_from=source ) + def generate_wear(item): + min_f = item.get("min_float", item.get("wear_range", [0, 1])[0]) + max_f = item.get("max_float", item.get("wear_range", [0, 1])[1]) + f = round(random.uniform(min_f, max_f), 6) + return f, get_wear_from_float(f) + if match: - # Check for multiplier activation on any reel for act in reel_activations: - if act == "double": - multiplier = 2 - break - elif act == "quintuple": + if act == "quintuple": multiplier = 5 break + elif act == "double": + multiplier = 2 + break - # Находим реальный предмет для приза matched_name = reels[0]["name"] prize_item = None for g in grouped_items: @@ -1855,17 +2084,10 @@ async def api_open_slot_case( break if prize_item: - prize_float = round(random.uniform(0.01, 0.45), 6) - prize_wear = "Field-Tested" - prize_id = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("market_hash_name") == prize_item.get("market_hash_name"): - prize_id = it.get("_id", idx) - break - + prize_float, prize_wear = generate_wear(prize_item) + prize_id = item_id_by_name(prize_item.get("market_hash_name")) prize_price = prize_item.get("price_rub", 0) - # Бонус: ближайший предмет стоимостью ≥ 1.5× цены кейса target_min_price = price_per_case * 1.5 candidates = [] for g in grouped_items: @@ -1877,13 +2099,8 @@ async def api_open_slot_case( if candidates: candidates.sort(key=lambda x: x[0]) bonus_price, bonus_variant, bonus_group = candidates[0] - bonus_float = round(random.uniform(0.01, 0.45), 6) - bonus_wear = "Field-Tested" - bonus_id = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("market_hash_name") == bonus_variant.get("market_hash_name"): - bonus_id = it.get("_id", idx) - break + bonus_float, bonus_wear = generate_wear(bonus_variant) + bonus_id = item_id_by_name(bonus_variant.get("market_hash_name")) bonus_data = { "id": bonus_id or bonus_variant.get("_id", 0), @@ -1895,10 +2112,8 @@ async def api_open_slot_case( "wear": bonus_wear } - # If multiplier > 1: find a single item worth (prize+bonus) * multiplier if multiplier > 1: total_value = (prize_price + bonus_price) * multiplier - # Find nearest item in ALL_ITEMS to this value best_item = None best_diff = float('inf') for idx, it in enumerate(ALL_ITEMS): @@ -1911,19 +2126,16 @@ async def api_open_slot_case( best_item = it if best_item: - multiplied_id = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("market_hash_name") == best_item.get("market_hash_name"): - multiplied_id = it.get("_id", idx) - break + mult_float, mult_wear = generate_wear(best_item) + multiplied_id = item_id_by_name(best_item.get("market_hash_name")) multiplied_prize = { "id": multiplied_id or best_item.get("_id", 0), "name": best_item.get("market_hash_name", "???"), "image_url": best_item.get("image_url", ""), "rarity": best_item.get("rarity", "Unknown"), "price": best_item.get("price_rub", 0), - "float": round(random.uniform(0.01, 0.45), 6), - "wear": "Field-Tested" + "float": mult_float, + "wear": mult_wear } prize_data = { @@ -1936,6 +2148,29 @@ async def api_open_slot_case( "wear": prize_wear } + db.add(make_item(prize_item, prize_data["id"], reels[0]["base_name"], + prize_data["wear"], prize_data["float"], f"slot: {case_name}")) + db.add(CaseOpening( + user_id=user.id, case_name=case_name, + item_id=prize_data["id"], + item_name=prize_data["name"], + rarity=prize_data["rarity"], + float_value=prize_data["float"] + )) + + if bonus_data: + bonus_item_data = {"rarity": bonus_data["rarity"], "type": "Normal"} + db.add(make_item(bonus_item_data, bonus_data["id"], + bonus_data["name"].rsplit(" (", 1)[0] if " (" in bonus_data["name"] else bonus_data["name"], + bonus_data["wear"], bonus_data["float"], f"slot_bonus: {case_name}")) + db.add(CaseOpening( + user_id=user.id, case_name=case_name, + item_id=bonus_data["id"], + item_name=bonus_data["name"], + rarity=bonus_data["rarity"], + float_value=bonus_data["float"] + )) + if multiplied_prize: mult_item_data = {"rarity": multiplied_prize["rarity"], "type": "Normal"} db.add(make_item(mult_item_data, multiplied_prize["id"], @@ -1949,37 +2184,13 @@ async def api_open_slot_case( rarity=multiplied_prize["rarity"], float_value=multiplied_prize["float"] )) - else: - db.add(make_item(prize_item, prize_data["id"], reels[0]["base_name"], - prize_data["wear"], prize_data["float"], f"slot: {case_name}")) - db.add(CaseOpening( - user_id=user.id, case_name=case_name, - item_id=prize_data["id"], - item_name=prize_data["name"], - rarity=prize_data["rarity"], - float_value=prize_data["float"] - )) - - if bonus_data: - bonus_item_data = {"rarity": bonus_data["rarity"], "type": "Normal"} - db.add(make_item(bonus_item_data, bonus_data["id"], - bonus_data["name"].rsplit(" (", 1)[0] if " (" in bonus_data["name"] else bonus_data["name"], - bonus_data["wear"], bonus_data["float"], f"slot_bonus: {case_name}")) - db.add(CaseOpening( - user_id=user.id, case_name=case_name, - item_id=bonus_data["id"], - item_name=bonus_data["name"], - rarity=bonus_data["rarity"], - float_value=bonus_data["float"] - )) elif "guaranteed" in reel_activations: - # No match but guaranteed activation: give consolation prize = cheapest affordable item - guaranteed_idx = reel_activations.index("guaranteed") guessed_group, guessed_item = pick_slot_reel(grouped_items, adjusted_weights) if guessed_item and guessed_group: g_price = guessed_item.get("price_rub", 0) if g_price > 0: + g_float, g_wear = generate_wear(guessed_item) prize_price = g_price prize_data = { "id": guessed_item.get("_id", 0), @@ -1987,8 +2198,8 @@ async def api_open_slot_case( "image_url": guessed_item.get("image_url", ""), "rarity": guessed_group["rarity"], "price": prize_price, - "float": round(random.uniform(0.01, 0.45), 6), - "wear": "Field-Tested" + "float": g_float, + "wear": g_wear } db.add(make_item( {"rarity": guessed_group["rarity"], "type": "Normal"}, @@ -2071,11 +2282,7 @@ async def api_open_multiple_cases( for v in group["variants"]: v_price = v.get("price_rub", 0) if cheapest_case_item is None or v_price < cheapest_case_item["price"]: - v_id = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("market_hash_name") == v.get("market_hash_name"): - v_id = it.get("_id", idx) - break + v_id = item_id_by_name(v.get("market_hash_name")) cheapest_case_item = { "id": v_id or v.get("_id", 0), "item": v, @@ -2084,83 +2291,101 @@ async def api_open_multiple_cases( "rarity": group["rarity"] } - # Получаем веса с учетом РПУ - adjusted_weights = get_adjusted_weights(db, user.id) - - results = [] - for _ in range(count): - result = open_case_with_variants_rpu(case_name, adjusted_weights) - if result: - item_data = result["item"] - item_float = result["float"] - item_id = result["id"] - base_name = result["group"]["base_name"] - - # Бан: выдаём самый дешёвый предмет если выпал дороже 75% цены кейса - if user.is_banned and cheapest_case_item: - item_price = item_data.get("price_rub", 0) - max_allowed_price = price_per_case * 0.75 - if item_price > max_allowed_price: - ci = cheapest_case_item - item_data = ci["item"] - item_id = ci["id"] - item_float = round(random.uniform(0.01, 0.45), 6) - base_name = ci["base_name"] - wear = "Field-Tested" - - wear = item_data.get("wear", "Field-Tested") - display_name = f"{base_name} ({wear})" - if item_data.get("type") == "StatTrak": - display_name = f"StatTrak™ {display_name}" - elif item_data.get("type") == "Souvenir": - display_name = f"Souvenir {display_name}" - - inventory_item = InventoryItem( - user_id=user.id, - item_id=item_id, - market_hash_name=display_name, - rarity=item_data.get("rarity", "Unknown"), - wear=wear, - float_value=item_float, - type=item_data.get("type", "Normal"), - obtained_from=f"case: {case_name}" - ) - db.add(inventory_item) - - opening = CaseOpening( - user_id=user.id, - case_name=case_name, - item_id=item_id, - item_name=display_name, - rarity=item_data.get("rarity", "Unknown"), - float_value=item_float - ) - db.add(opening) - - price = item_data.get("price_rub", 0) - - results.append({ - "id": item_id, - "name": display_name, - "rarity": item_data.get("rarity"), - "float": item_float, - "image_url": item_data.get("image_url", ""), - "wear": wear, - "price": price - }) - - user.balance -= total_price - - # Обновляем статистику РПУ - update_rpu_stats(db, user.id, total_price, count) - - db.commit() + # Включаем batch-режим — все sub-функции делают flush вместо commit + import rpu as _rpu_mod + _rpu_mod._BATCH_MODE = True + try: + # Один раз считаем RPU на всю пачку (кэшируется внутри) + adjusted_weights = get_adjusted_weights(db, user.id) + results = [] + for _ in range(count): + result = open_case_with_variants_rpu(case_name, adjusted_weights) + if result: + item_data = result["item"] + item_float = result["float"] + item_id = result["id"] + base_name = result["group"]["base_name"] + + # Бан: выдаём самый дешёвый предмет если выпал дороже 75% цены кейса + if user.is_banned and cheapest_case_item: + item_price = item_data.get("price_rub", 0) + max_allowed_price = price_per_case * 0.75 + if item_price > max_allowed_price: + ci = cheapest_case_item + item_data = ci["item"] + item_id = ci["id"] + item_float = round(random.uniform(0.01, 0.45), 6) + base_name = ci["base_name"] + wear = "Field-Tested" + + wear = item_data.get("wear", "Field-Tested") + display_name = f"{base_name} ({wear})" + if item_data.get("type") == "StatTrak": + display_name = f"StatTrak™ {display_name}" + elif item_data.get("type") == "Souvenir": + display_name = f"Souvenir {display_name}" + + price = item_data.get("price_rub", 0) or 100 + inventory_item = InventoryItem( + user_id=user.id, + item_id=item_id, + market_hash_name=display_name, + rarity=item_data.get("rarity", "Unknown"), + wear=wear, + float_value=item_float, + type=item_data.get("type", "Normal"), + image_url=item_data.get("image_url", ""), + price_rub=price, + obtained_from=f"case: {case_name}" + ) + db.add(inventory_item) + + opening = CaseOpening( + user_id=user.id, + case_name=case_name, + item_id=item_id, + item_name=display_name, + rarity=item_data.get("rarity", "Unknown"), + float_value=item_float + ) + db.add(opening) + + results.append({ + "id": item_id, + "name": display_name, + "rarity": item_data.get("rarity"), + "float": item_float, + "image_url": item_data.get("image_url", ""), + "wear": wear, + "price": price + }) + + user.balance -= total_price + + # Обновляем статистику РПУ + total_received = sum(r.get("price", 0) for r in results) + had_rare = any(r.get("rarity") in ("Covert", "Rare Special Item", "Extraordinary") for r in results) + update_rpu_stats(db, user.id, total_price, count, total_received, + rarity=results[0]["rarity"] if results else None, is_rare=had_rare) + + db.commit() + finally: + _rpu_mod._BATCH_MODE = False + _rpu_mod._batch_cache_clear() # Запись активности — каждый предмет отдельно for r in results: + item_price = r.get("price", 0) + mult = round(item_price / max(1, price_per_case), 1) if item_price > 0 else 0 + effect = f"drop:{mult}x" if mult >= 3 else None + act_data = { + "case": case_name, "item": r["name"], "rarity": r.get("rarity", ""), + "item_image": r.get("image_url", ""), "mult": mult + } + if effect: + act_data["effect"] = effect act = record_activity_own_session(user.id, user.username, "case_open", - f"📦 открыл(а) кейс и получил(а) {r['name']}", - {"case": case_name, "item": r["name"], "rarity": r.get("rarity", ""), "item_image": r.get("image_url", "")}) + f"📦 открыл(а) кейс и получил(а) {r['name']}", act_data) try: asyncio.ensure_future(manager.broadcast({ "type": "activity", @@ -2169,6 +2394,24 @@ async def api_open_multiple_cases( except: pass + # Проверка достижений + unlocked_ach = [] + unlocked_ach += check_open_cases_achievements(db, user.id) + unlocked_ach += check_spent_achievements(db, user.id, get_user_rpu(db, user.id).total_spent) + unlocked_ach += check_inventory_achievements(db, user.id) + unlocked_ach += check_dildo_achievements(db, user.id) + unlocked_ach += check_items_unboxed_achievements(db, user.id) + unlocked_ach += check_rare_item_count_achievements(db, user.id) + unlocked_ach += check_knife_count_achievements(db, user.id) + unlocked_ach += check_balance_achievements(db, user.id) + unlocked_ach += check_achievements_count_achievements(db, user.id) + + for r in results: + unlocked_ach += check_rare_item_achievement(db, user.id, r.get("rarity", "")) + item_full = get_item(r.get("id", 0)) + if item_full: + unlocked_ach += check_knife_achievement(db, user.id, item_full) + asyncio.ensure_future(manager.send_personal(user.id, {"type": "inventory_update"})) asyncio.ensure_future(manager.send_personal(user.id, {"type": "balance_update", "balance": user.balance})) @@ -2177,7 +2420,8 @@ async def api_open_multiple_cases( "results": results, "count": len(results), "spent": total_price, - "new_balance": user.balance + "new_balance": user.balance, + "achievements_unlocked": unlocked_ach if unlocked_ach else None }) @@ -2198,14 +2442,14 @@ async def api_sell_item( if not inv_item: return JSONResponse(status_code=400, content={"error": "Предмет не найден"}) - # Получаем цену предмета - item_data = get_item(inv_item.item_id) - if not item_data: - return JSONResponse(status_code=400, content={"error": "Данные предмета не найдены"}) - - price = item_data.get('price_rub', 0) + # Получаем цену предмета (используем сохранённую при получении, не из ALL_ITEMS) + price = inv_item.price_rub if price <= 0: - price = 100 # Минимальная цена если не указана + item_data = get_item(inv_item.item_id) + if item_data: + price = item_data.get('price_rub', 0) + if price <= 0: + price = 100 # Минимальная цена если не указана # Начисляем деньги user.balance += price @@ -2255,14 +2499,13 @@ async def api_sell_bulk_items( sold_count = 0 for inv_item in items_to_sell: - # Получаем цену предмета - item_data = get_item(inv_item.item_id) - if item_data: - price = item_data.get('price_rub', 100) + price = inv_item.price_rub + if price <= 0: + item_data = get_item(inv_item.item_id) + if item_data: + price = item_data.get('price_rub', 0) if price <= 0: price = 100 - else: - price = 100 total_received += price sold_count += 1 @@ -2308,14 +2551,7 @@ async def upgrade_page( # Обогащаем предметы данными из ALL_ITEMS for item in inventory_items_db: - item_data = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("_id", idx) == item.item_id: - item_data = it - break - - if not item_data: - item_data = get_item(item.item_id) + item_data = get_item(item.item_id) if item_data: enriched_items.append({ @@ -2323,7 +2559,7 @@ async def upgrade_page( "item_id": item.item_id, "name": item.market_hash_name, "rarity": item.rarity, - "price_rub": item_data.get("price_rub", 100), + "price_rub": item_data.get("price_rub", 0) or 0, "image_url": item_data.get("image_url", ""), "wear": item.wear, "float": item.float_value @@ -2393,23 +2629,9 @@ async def api_calculate_upgrade( if not input_item: return JSONResponse(status_code=400, content={"error": "Предмет не найден"}) - # Получаем данные предметов из ALL_ITEMS - input_data = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("_id", idx) == input_item.item_id: - input_data = it - break - - target_data = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("_id", idx) == target_item_id: - target_data = it - break - - if not input_data: - input_data = get_item(input_item.item_id) - if not target_data: - target_data = get_item(target_item_id) + # Получаем данные предметов (O(1) через ITEM_BY_ID) + input_data = get_item(input_item.item_id) + target_data = get_item(target_item_id) if not input_data or not target_data: return JSONResponse(status_code=400, content={"error": "Данные предметов не найдены"}) @@ -2495,22 +2717,8 @@ async def api_execute_upgrade( if not input_item: return JSONResponse(status_code=400, content={"error": "Предмет не найден"}) - input_data = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("_id", idx) == input_item.item_id: - input_data = it - break - - target_data = None - for idx, it in enumerate(ALL_ITEMS): - if it.get("_id", idx) == target_item_id: - target_data = it - break - - if not input_data: - input_data = get_item(input_item.item_id) - if not target_data: - target_data = get_item(target_item_id) + input_data = get_item(input_item.item_id) + target_data = get_item(target_item_id) if not input_data or not target_data: return JSONResponse(status_code=400, content={"error": "Данные предметов не найдены"}) @@ -2545,7 +2753,7 @@ async def api_execute_upgrade( db.delete(input_item) if success: - item_float = generate_item_float(target_data.get("rarity", "Unknown")) + item_float = target_data.get("mid_float_used") or generate_item_float(target_data.get("rarity", "Unknown")) new_item = InventoryItem( user_id=user.id, item_id=target_item_id, @@ -2554,6 +2762,8 @@ async def api_execute_upgrade( wear=target_data.get("wear", "Unknown"), float_value=item_float, type=target_data.get("type", "Normal"), + image_url=target_data.get("image_url", ""), + price_rub=target_data.get("price_rub", 0), obtained_from="upgrade" ) db.add(new_item) @@ -2574,30 +2784,42 @@ async def api_execute_upgrade( db.add(upgrade) # Обновляем статистику - update_upgrade_stats(db, user.id, success, input_price) - + import rpu as _rpu_mod + _rpu_mod._BATCH_MODE = True + try: + update_upgrade_stats(db, user.id, success, input_price) + + unlocked_ach = check_upgrade_achievements(db, user.id) + unlocked_ach += check_upgrade_risk_achievements(db, user.id, input_price) + unlocked_ach += check_upgrade_result_achievements(db, user.id, success, base_probability, input_price) + unlocked_ach += check_inventory_achievements(db, user.id) + unlocked_ach += check_dildo_achievements(db, user.id) + unlocked_ach += check_balance_achievements(db, user.id) + unlocked_ach += check_collection_achievements(db, user.id) + unlocked_ach += check_achievements_count_achievements(db, user.id) + + if success: + prob_pct = round(base_probability, 1) if base_probability > 0 else 0 + effect = f"upgrade:{prob_pct}%" if prob_pct <= 25 and prob_pct > 0 else None + act_data = { + "input": saved_input_name, "target": target_data.get("market_hash_name", ""), + "success": True, "item_image": target_data.get("image_url", ""), + "probability": prob_pct + } + if effect: + act_data["effect"] = effect + act = record_activity_own_session(user.id, user.username, "upgrade", + f"✅ выиграл(а) апгрейд: {saved_input_name} → {target_data.get('market_hash_name', '???')}", act_data, db=db) + try: + asyncio.ensure_future(manager.broadcast({ + "type": "activity", "activity": act + })) + except: + pass + finally: + _rpu_mod._BATCH_MODE = False + db.commit() - - unlocked_ach = check_upgrade_achievements(db, user.id) - unlocked_ach += check_upgrade_risk_achievements(db, user.id, input_price) - unlocked_ach += check_upgrade_result_achievements(db, user.id, success, base_probability, input_price) - unlocked_ach += check_dildo_achievements(db, user.id) - unlocked_ach += check_balance_achievements(db, user.id) - unlocked_ach += check_collection_achievements(db, user.id) - unlocked_ach += check_achievements_count_achievements(db, user.id) - - if success: - act = record_activity_own_session(user.id, user.username, "upgrade", - f"✅ выиграл(а) апгрейд: {saved_input_name} → {target_data.get('market_hash_name', '???')}", - {"input": saved_input_name, "target": target_data.get("market_hash_name", ""), "success": True, "item_image": target_data.get("image_url", "")}) - try: - asyncio.ensure_future(manager.broadcast({ - "type": "activity", - "activity": act - })) - except: - pass - asyncio.ensure_future(manager.send_personal(user.id, {"type": "inventory_update"})) asyncio.ensure_future(manager.send_personal(user.id, {"type": "balance_update", "balance": user.balance})) @@ -2656,36 +2878,6 @@ async def api_get_achievements( }) -# ═══════════════════════════════════════════════════════════════════════════════ -# 📰 Лента активностей -# ═══════════════════════════════════════════════════════════════════════════════ - -@app.get("/activity", response_class=HTMLResponse) -async def activity_page( - request: Request, - db: Session = Depends(get_db), - user: Optional[User] = Depends(get_current_user_optional) -): - activities = db.query(ActivityFeed).order_by(desc(ActivityFeed.created_at)).limit(50).all() - - return templates.TemplateResponse(request, "activity.html", { - "user": user, - "activities": [ - { - "id": a.id, - "user_id": a.user_id, - "username": a.username, - "activity_type": a.activity_type, - "message": a.message, - "data": json.loads(a.data_json) if a.data_json else {}, - "created_at": a.created_at.isoformat() - } - for a in activities - ], - "active_page": "activity" - }) - - @app.get("/web/api/activity") async def api_get_activity( limit: int = 50, @@ -2804,9 +2996,23 @@ async def crash_game_loop(): await asyncio.sleep(4) -@app.on_event("startup") -async def startup(): +from contextlib import asynccontextmanager + +@asynccontextmanager +async def lifespan(app_instance): asyncio.create_task(crash_game_loop()) + # Генерируем первый авто-промокод при старте + try: + from database import SessionLocal + db = SessionLocal() + from promo_service import get_or_create_auto_promo + get_or_create_auto_promo(db) + db.close() + except Exception: + pass + yield + +app.router.lifespan_context = lifespan @app.get("/crash", response_class=HTMLResponse) diff --git a/mainpage.json b/mainpage.json index 270976c..d437930 100644 --- a/mainpage.json +++ b/mainpage.json @@ -12,18 +12,18 @@ "cases": [ "CS:GO Weapon Case", "Revolution Case", - "Recoil Case" + "Recoil Case", + "Prisma 2 Case", + "Prisma Case", + "Fracture Case", + "Danger Zone Case" ] }, { "tab": "Новинки", "cases": [ "allin1", - "Danger Zone Case", - "Prisma Case", - "Fracture Case", - "Prisma 2 Case", - "m9orbutterfly1" + "Revolvernoe" ] }, { diff --git a/promo_phase.json b/promo_phase.json new file mode 100644 index 0000000..06552dc --- /dev/null +++ b/promo_phase.json @@ -0,0 +1,5 @@ +{ + "phase": "LUCKY", + "until": "2026-07-22T19:34:16.536290", + "started_at": "2026-07-22T18:22:18.082890" +} \ No newline at end of file diff --git a/promo_phase.py b/promo_phase.py new file mode 100644 index 0000000..6f5b0b7 --- /dev/null +++ b/promo_phase.py @@ -0,0 +1,178 @@ +""" +Promo Phase — глобальное скрытое состояние "промо-фазы". +Влияет на то, какой эффект дают промокоды (везёт / не везёт / нейтрально). +Переключается случайно каждые 1-6 часов. +""" + +import json +import random +from datetime import datetime, timedelta +from pathlib import Path + +PHASE_FILE = Path("promo_phase.json") + +PHASE_CONFIG = { + "LUCKY": { + "weight": 0.4, + "min_hours": 1, + "max_hours": 4, + "effect": "boost", + "boost_range": (0.15, 0.35), # +15-35% к luck_multiplier + "rare_chance_boost": 0.05, # +5% к шансу редкого + }, + "UNLUCKY": { + "weight": 0.4, + "min_hours": 1, + "max_hours": 4, + "effect": "penalty", + "penalty_range": (0.10, 0.25), # -10-25% к luck_multiplier + "ceiling_penalty": 0.10, # -10% к потолку + }, + "NEUTRAL": { + "weight": 0.2, + "min_hours": 1, + "max_hours": 3, + "effect": "none", + } +} + + +def load_phase(): + """Загружает текущую промо-фазу из файла""" + if PHASE_FILE.exists(): + try: + with open(PHASE_FILE, 'r') as f: + return json.load(f) + except: + pass + + return { + "phase": "NEUTRAL", + "until": datetime.utcnow().isoformat(), + "started_at": datetime.utcnow().isoformat() + } + + +def save_phase(state: dict): + """Сохраняет промо-фазу""" + with open(PHASE_FILE, 'w') as f: + json.dump(state, f, indent=2) + + +def pick_next_phase() -> str: + """Выбирает следующую фазу случайно, с учётом весов""" + phases = list(PHASE_CONFIG.keys()) + weights = [PHASE_CONFIG[p]["weight"] for p in phases] + return random.choices(phases, weights=weights, k=1)[0] + + +def get_random_duration(phase: str) -> timedelta: + """Случайная длительность фазы""" + config = PHASE_CONFIG.get(phase, PHASE_CONFIG["NEUTRAL"]) + min_h = config["min_hours"] + max_h = config["max_hours"] + hours = random.uniform(min_h, max_h) + return timedelta(hours=hours) + + +def ensure_phase(): + """Проверяет и при необходимости переключает фазу""" + state = load_phase() + until = datetime.fromisoformat(state["until"]) + + if datetime.utcnow() >= until: + new_phase = pick_next_phase() + duration = get_random_duration(new_phase) + state["phase"] = new_phase + state["until"] = (datetime.utcnow() + duration).isoformat() + state["started_at"] = datetime.utcnow().isoformat() + save_phase(state) + + return state["phase"] + + +def get_current_phase() -> str: + """Возвращает текущую фазу""" + return ensure_phase() + + +def get_promo_effect_for_user(user_id: int, hot_score: float = 0.0, + withdrawal_ratio: float = 0.0, + is_cold: bool = False) -> dict: + """ + Возвращает индивидуальный эффект промо-фазы для пользователя. + Одинаковая фаза → разный эффект в зависимости от hot/cold/withdrawal. + """ + phase = get_current_phase() + config = PHASE_CONFIG.get(phase, {}) + effect_type = config.get("effect", "none") + + result = { + "phase": phase, + "effect": effect_type, + "luck_modifier": 0.0, # additive к luck_multiplier + "ceiling_modifier": 0.0, # additive к ceiling + "rare_chance_modifier": 0.0, # additive к шансу редкого + "description": "" + } + + if effect_type == "boost": + boost = random.uniform(*config["boost_range"]) + + # Холодные игроки получают БОЛЬШЕ буста (реактивация) + if is_cold: + boost *= 1.5 + # Кто много вывел — получает МЕНЬШЕ + boost *= max(0.5, 1.0 - withdrawal_ratio * 0.5) + + result["luck_modifier"] = boost + result["rare_chance_modifier"] = config.get("rare_chance_boost", 0) + result["description"] = "Промо-удача 🍀" + + elif effect_type == "penalty": + penalty = random.uniform(*config["penalty_range"]) + + # Горячие (много потратили, 0 выводов) почти не чувствуют пенальти + if hot_score > 10000 and withdrawal_ratio < 0.1: + penalty *= 0.3 + # Кто вывел много — получает ПОЛНУЮ открутку + if withdrawal_ratio > 0.5: + penalty *= 1.5 + + result["luck_modifier"] = -penalty + result["ceiling_modifier"] = -config.get("ceiling_penalty", 0) + result["description"] = "Промо-открутка 💀" + + return result + + +def get_promo_phase_info() -> dict: + """Возвращает инфо о фазе: phase, effect, next_switch, remaining_minutes.""" + state = load_phase() + phase = state["phase"] + until = datetime.fromisoformat(state["until"]) + config = PHASE_CONFIG.get(phase, {}) + remaining = (until - datetime.utcnow()).total_seconds() + return { + "phase": phase, + "effect": config.get("effect", "none"), + "next_switch": until.isoformat(), + "remaining_minutes": max(0, int(remaining // 60)), + } + + +def get_phase_debug_info() -> dict: + """Для админки — отладка промо-фаз""" + state = load_phase() + phase = state["phase"] + until = datetime.fromisoformat(state["until"]) + started = datetime.fromisoformat(state["started_at"]) + remaining = (until - datetime.utcnow()).total_seconds() + + return { + "current_phase": phase, + "started_at": started.isoformat(), + "until": until.isoformat(), + "remaining_seconds": max(0, int(remaining)), + "config": PHASE_CONFIG.get(phase, {}) + } diff --git a/promo_service.py b/promo_service.py new file mode 100644 index 0000000..48ede72 --- /dev/null +++ b/promo_service.py @@ -0,0 +1,352 @@ +""" +Promo Service — логика промокодов и депозитов. +""" + +from datetime import datetime, timedelta +import random +from typing import Optional +from sqlalchemy.orm import Session +from database import User, PromoCode, TransactionLog +from bank_api import bank_client +from promo_phase import get_promo_effect_for_user, get_current_phase + +DAILY_DEPOSIT_LIMIT = 999999999.0 # без лимита + +# ── Авто-генерация промокодов ── +PROMO_WORDS = ["storm", "glitch", "frost", "blaze", "cipher", "nexus", "phantom", "prism"] +PROMO_PERCENTS = [10, 15, 20, 21, 24, 25] + +# Хранилище текущего авто-промокода (in-memory кэш) +_current_auto_promo: Optional[dict] = None + + +def generate_auto_promo_code() -> str: + """Генерирует промокод формата слово-процент, например 'storm-21'""" + word = random.choice(PROMO_WORDS) + pct = random.choice(PROMO_PERCENTS) + return f"{word}-{pct}" + + +def get_or_create_auto_promo(db: Session) -> PromoCode: + """ + Возвращает активный авто-сгенерированный промокод. + Если его нет или он истёк (>1ч) — создаёт новый. + """ + global _current_auto_promo + now = datetime.utcnow() + + # Ищем существующий авто-промокод, созданный <1 часа назад + candidates = db.query(PromoCode).filter( + PromoCode.reward_type == "card_to_site", + PromoCode.is_active == True, + PromoCode.created_by == -1 # маркер авто-генерации + ).order_by(PromoCode.created_at.desc()).limit(1).all() + + if candidates: + promo = candidates[0] + age = (now - promo.created_at).total_seconds() + if age < 3600: # меньше часа — живой + _current_auto_promo = { + "code": promo.code, + "reward_amount": promo.reward_amount, + "created_at": promo.created_at.isoformat(), + } + return promo + + # Создаём новый + code = generate_auto_promo_code() + pct = int(code.split("-")[1]) + reward = pct # процент от daily лимита как сумма бонуса + + new_promo = PromoCode( + code=code, + reward_type="card_to_site", + reward_amount=reward, + reward_data="auto", + max_uses=999, + used_count=0, + start_date=now, + end_date=now + timedelta(hours=1), + is_active=True, + created_by=-1, + ) + db.add(new_promo) + db.commit() + + _current_auto_promo = { + "code": new_promo.code, + "reward_amount": new_promo.reward_amount, + "created_at": new_promo.created_at.isoformat(), + } + return new_promo + + +def get_current_auto_promo(db: Session) -> Optional[dict]: + """Возвращает информацию о текущем авто-промокоде.""" + promo = get_or_create_auto_promo(db) + now = datetime.utcnow() + age = (now - promo.created_at).total_seconds() + remaining = max(0, 3600 - age) + return { + "code": promo.code, + "reward_amount": promo.reward_amount, + "created_at": promo.created_at.isoformat(), + "expires_in_seconds": int(remaining), + "used_count": promo.used_count, + } + + +def validate_promo_code(db: Session, code: str) -> Optional[dict]: + """Проверяет промокод, возвращает данные или None""" + promo = db.query(PromoCode).filter( + PromoCode.code == code, + PromoCode.is_active == True + ).first() + + if not promo: + return None + + now = datetime.utcnow() + + if promo.end_date and now > promo.end_date: + return {"error": "Срок действия промокода истёк"} + + if promo.start_date and now < promo.start_date: + return {"error": "Промокод ещё не активен"} + + if promo.max_uses > 0 and promo.used_count >= promo.max_uses: + return {"error": "Промокод уже использован максимальное количество раз"} + + return { + "id": promo.id, + "code": promo.code, + "reward_type": promo.reward_type, + "reward_amount": promo.reward_amount, + "reward_data": promo.reward_data, + } + + +async def activate_promo_code(db: Session, user_id: int, code: str, amount: float = 0) -> dict: + """ + Активирует промокод. + Типы: card_to_site, luck_boost, ceiling_boost, free_case, reset_streak + """ + promo_data = validate_promo_code(db, code) + if not promo_data: + return {"success": False, "error": "Промокод не найден"} + if "error" in promo_data: + return {"success": False, "error": promo_data["error"]} + + promo = db.query(PromoCode).filter(PromoCode.id == promo_data["id"]).first() + if not promo: + return {"success": False, "error": "Ошибка загрузки промокода"} + + user = db.query(User).filter(User.id == user_id).first() + if not user: + return {"success": False, "error": "Пользователь не найден"} + + rpu = user.rpu_settings + result = {"success": True, "code": code, "type": promo.reward_type} + promo.used_count += 1 + + # Записываем транзакцию + txn = TransactionLog( + user_id=user_id, + tx_type="promo_bonus", + amount=0, + promo_code=code, + details=f"Промокод: {code} ({promo.reward_type})" + ) + db.add(txn) + + if promo.reward_type == "card_to_site": + if amount <= 0: + return {"success": False, "error": "Укажите сумму депозита"} + + pct = promo.reward_amount # например 24 → 24% + bonus = round(amount * pct / 100, 2) + total = round(amount + bonus, 2) + + # Проверяем баланс карты (требуем только сумму депозита) + bank_balance = await bank_client.get_balance(user_id) + + if bank_balance < amount: + return {"success": False, "error": f"Недостаточно средств на карте. Нужно: {amount}₽"} + + # Списываем с карты только депозит + await bank_client.transfer_to_site(user_id, amount, promo_code=code) + + # Зачисляем на сайт (депозит + бонус от платформы) + user.balance += total + user.total_deposited += amount + + txn.amount = total + txn.details = f"Депозит {amount}₽ + бонус {pct}% ({bonus}₽) по промокоду {code}" + + result["amount"] = total + result["deposit"] = amount + result["bonus"] = bonus + result["new_balance"] = user.balance + + elif promo.reward_type == "luck_boost": + boost = promo.reward_amount / 100.0 # например 20 → 0.2 + if rpu: + rpu.luck_multiplier = min(3.0, rpu.luck_multiplier + boost) + result["boost"] = boost + + elif promo.reward_type == "ceiling_boost": + boost_mult = promo.reward_amount / 100.0 + 1.0 # 50 → 1.5 + if rpu: + rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * boost_mult) + result["ceiling_boost"] = boost_mult + + elif promo.reward_type == "free_case": + # Бесплатное открытие — просто отмечаем в результате + result["free_case"] = promo.reward_data or "" + + elif promo.reward_type == "reset_streak": + if rpu: + rpu.current_streak = 0 + rpu.consecutive_loss_count = 0 + rpu.consecutive_loss_value = 0.0 + result["reset_streak"] = True + + db.commit() + return result + + +async def direct_deposit(db: Session, user_id: int, amount: float, promo_code: str = "") -> dict: + """ + Прямой депозит с карты на сайт. + Если указан промокод — применяет бонус поверх депозита. + """ + if amount <= 0: + return {"success": False, "error": "Сумма должна быть положительной"} + + user = db.query(User).filter(User.id == user_id).first() + if not user: + return {"success": False, "error": "Пользователь не найден"} + + # Проверка промокода (если указан) + bonus = 0 + promo_pct = 0 + if promo_code: + promo_data = validate_promo_code(db, promo_code) + if promo_data and "error" not in promo_data: + promo = db.query(PromoCode).filter(PromoCode.id == promo_data["id"]).first() + if promo and promo.reward_type == "card_to_site": + promo_pct = promo.reward_amount + bonus = round(amount * promo_pct / 100, 2) + promo.used_count += 1 + + total = round(amount + bonus, 2) + + # Проверка дневного лимита + today = datetime.utcnow().date() + daily_total = db.query(TransactionLog).filter( + TransactionLog.user_id == user_id, + TransactionLog.tx_type == "card_to_site", + ).all() + + today_deposits = sum( + t.amount for t in daily_total + if t.created_at.date() == today + ) + + if today_deposits + amount > DAILY_DEPOSIT_LIMIT: + remaining = max(0, DAILY_DEPOSIT_LIMIT - today_deposits) + return { + "success": False, + "error": f"Дневной лимит депозита: {remaining:.2f}₽" + } + + # Проверяем баланс карты + bank_balance = await bank_client.get_balance(user_id) + + if bank_balance < amount: + return {"success": False, "error": "Недостаточно средств на карте"} + + # Списываем с карты (только депозит, бонус от платформы) + await bank_client.transfer_to_site(user_id, amount) + + # Зачисляем на сайт + user.balance += total + user.total_deposited += amount + user.last_deposit_date = datetime.utcnow() + + details = f"Прямой депозит: +{amount}₽" + if bonus > 0: + details += f" + бонус {promo_pct}% ({bonus}₽)" + + txn = TransactionLog( + user_id=user_id, + tx_type="card_to_site", + amount=total, + promo_code=promo_code if promo_code else None, + details=details + ) + db.add(txn) + db.commit() + + return { + "success": True, + "amount": total, + "deposit": amount, + "bonus": bonus, + "new_balance": user.balance, + "daily_remaining": max(0, DAILY_DEPOSIT_LIMIT - today_deposits - amount) + } + + +async def withdraw_item(db: Session, user_id: int, inventory_item_id: int) -> dict: + """ + Вывод скина: продажа предмета из инвентаря → зачисление на карту (с комиссией). + """ + from database import InventoryItem + + item = db.query(InventoryItem).filter( + InventoryItem.id == inventory_item_id, + InventoryItem.user_id == user_id + ).first() + + if not item: + return {"success": False, "error": "Предмет не найден в инвентаре"} + + price = item.price_rub or 0 + if price <= 0: + return {"success": False, "error": "Предмет не имеет цены"} + + user = db.query(User).filter(User.id == user_id).first() + + result = await bank_client.withdraw_to_card(user_id, price) + + if not result["success"]: + return {"success": False, "error": "Ошибка вывода"} + + # Обновляем статистику пользователя + user.total_withdrawn += result["net"] # net = price - fee + db.delete(item) + + txn = TransactionLog( + user_id=user_id, + tx_type="site_to_card", + amount=price, + fee=result["fee"], + item_name=item.market_hash_name, + inventory_item_id=inventory_item_id, + details=f"Вывод: {item.market_hash_name} за {price}₽ (комиссия {result['fee']}₽)" + ) + db.add(txn) + db.commit() + + from rpu import check_withdrawal_penalty + check_withdrawal_penalty(db, user_id) + + return { + "success": True, + "item_name": item.market_hash_name, + "gross": price, + "fee": result["fee"], + "net": result["net"], + "new_card_balance": result["new_card_balance"] + } diff --git a/rpu.py b/rpu.py index 0070ba2..b5e09e0 100644 --- a/rpu.py +++ b/rpu.py @@ -1,8 +1,11 @@ +import json import random -from datetime import datetime -from typing import Dict, Optional +import math +from datetime import datetime, date +from typing import Dict, Optional, Tuple from sqlalchemy.orm import Session -from database import User, UserRPU +from database import User, UserRPU, InventoryItem +from promo_phase import get_promo_effect_for_user, get_current_phase # Стандартные веса редкостей BASE_RARITY_WEIGHTS = { @@ -17,7 +20,6 @@ BASE_RARITY_WEIGHTS = { "Extraordinary": 5 } -# Маппинг редкостей на поля в UserRPU RARITY_TO_FIELD = { "Consumer Grade": "consumer_multiplier", "Industrial Grade": "industrial_multiplier", @@ -30,77 +32,463 @@ RARITY_TO_FIELD = { "Extraordinary": "rare_special_multiplier" } +RARITY_RANK = { + "Consumer Grade": 0, + "Industrial Grade": 1, + "Mil-Spec": 2, + "Mil-Spec Grade": 2, + "Restricted": 3, + "Classified": 4, + "Covert": 5, + "Rare Special Item": 6, + "Extraordinary": 6 +} + +# Пороги потолка +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+ сессий +} + +# Пороги для 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%, удача падает + +# Комбек +COMEBACK_TRIGGER_COUNT = 5 # 5+ проигрышей подряд → комбек +COMEBACK_TRIGGER_VALUE_RATIO = 0.3 # потеря >30% от total_deposited +COMEBACK_OPENINGS = 5 # комбек на 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₽/день = холодный + def get_user_rpu(db: Session, user_id: int) -> UserRPU: - """Получает или создает настройки РПУ для пользователя""" rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first() if not rpu: rpu = UserRPU(user_id=user_id) db.add(rpu) - db.commit() + _rpu_commit_or_flush(db) db.refresh(rpu) return rpu -def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]: +# ── 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() + +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) + + +def regen_luck_budget(db: Session, user_id: int): + rpu = get_user_rpu(db, user_id) + rpu.luck_budget = min(BUDGET_MAX, rpu.luck_budget + BUDGET_REGEN_PER_OPEN) + _rpu_commit_or_flush(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() + 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) + + +def apply_comeback(db: Session, user_id: int) -> float: + """ + Применяет комбек-множитель если активен. + Возвращает множитель для следующего открытия. + """ + rpu = 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) + 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() + 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) + 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 + 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) + + 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() + 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.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) + + +# ── V2: Enhanced get_adjusted_weights ── + +def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]: + """ + Получает веса редкостей с учетом РПУ v2: + - сессии, потолок, luck_budget, комбек, промо-фаза, hot/cold - # Вычисляем общий уровень РПУ (0-100%) - rpu_level = calculate_rpu_level(rpu) - - # Переворачиваем: низкий РПУ = высокий множитель для редких - # При 0% РПУ множитель для редких = 2.0 (очень везёт) - # При 50% РПУ множитель = 1.0 (обычно) - # При 100% РПУ множитель = 0.1 (совсем не везёт) - + В batch-режиме результат кэшируется — повторные вызовы с тем же user_id + возвращают закешированное значение без DB-запросов. + """ + 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) + + # Промо-фаза + promo = get_promo_effect_for_user(user_id, rpu.hot_score, wd_ratio, is_cold) + + # Комбек + comeback_mult = apply_comeback(db, user_id) + + # Budget factor + budget_factor = get_budget_factor(rpu) + + # Ceiling factor — не блокирует, а лишь приглушает удачу + ceiling_factor = get_ceiling_luck_factor(db, user_id) + adjusted = {} for rarity, base_weight in BASE_RARITY_WEIGHTS.items(): field = RARITY_TO_FIELD.get(rarity) base_multiplier = getattr(rpu, field, 1.0) if field else 1.0 - - # Определяем "редкость" предмета (чем реже, тем больше индекс) - rarity_index = 0 - if "Industrial" in rarity: - rarity_index = 1 - elif "Mil-Spec" in rarity: - rarity_index = 2 - elif "Restricted" in rarity: - rarity_index = 3 - elif "Classified" in rarity: - rarity_index = 4 - elif "Covert" in rarity: - rarity_index = 5 - elif "Rare Special" in rarity or "Extraordinary" in rarity: - rarity_index = 6 - - # Чем выше РПУ, тем сильнее режем шансы на редкое - # Чем ниже РПУ, тем сильнее повышаем шансы на редкое + rarity_index = RARITY_RANK.get(rarity, 0) + if rpu_level < 50: - # Везёт: повышаем шансы на редкое luck_factor = 1.0 + ((50 - rpu_level) / 50) * rarity_index * 0.3 else: - # Не везёт: понижаем шансы на редкое luck_factor = 1.0 - ((rpu_level - 50) / 50) * rarity_index * 0.2 - + luck_factor = max(0.1, min(3.0, luck_factor)) - - final_multiplier = base_multiplier * rpu.luck_multiplier * luck_factor - adjusted[rarity] = base_weight * final_multiplier - + + # Применяем все множители + 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)) + + final = max(0.05, min(5.0, final)) + adjusted[rarity] = base_weight * final + + _batch_cache_set(user_id, adjusted) return adjusted def calculate_rpu_level(rpu: UserRPU) -> float: - """ - Вычисляет общий уровень РПУ (0-100%). - 0% = максимальное везение - 100% = максимальное невезение - """ multipliers = [ rpu.consumer_multiplier, rpu.industrial_multiplier, @@ -110,22 +498,18 @@ def calculate_rpu_level(rpu: UserRPU) -> float: rpu.covert_multiplier, rpu.rare_special_multiplier ] - - # Средний множитель + avg = sum(multipliers) / len(multipliers) - - # Преобразуем в проценты: 0.1x = 100% РПУ (не везёт), 3.0x = 0% РПУ (везёт) - # 1.0x = 50% РПУ + if avg >= 1.0: level = 50 - ((avg - 1.0) / 2.0) * 100 else: level = 50 + ((1.0 - avg) / 0.9) * 50 - + return max(0, min(100, level)) def get_rpu_description(level: float) -> str: - """Возвращает текстовое описание уровня РПУ""" if level < 20: return "🍀 Очень везучий" elif level < 35: @@ -143,50 +527,158 @@ def get_rpu_description(level: float) -> str: def auto_adjust_rpu(db: Session, user_id: int): - """Автоматическая подкрутка РПУ на основе статистики""" rpu = get_user_rpu(db, user_id) - if not rpu.auto_adjust: return - + user = db.query(User).filter(User.id == user_id).first() if not user: return - - # Анализируем статистику - if rpu.total_opened > 0: - avg_spent_per_case = rpu.total_spent / rpu.total_opened - - # Если пользователь много тратит и мало получает - снижаем РПУ (даём везение) - if rpu.total_spent > 10000 and rpu.total_opened > 20: - # Снижаем РПУ на 5-15% - reduction = min(0.15, rpu.total_spent / 100000) - rpu.luck_multiplier = min(2.0, rpu.luck_multiplier + reduction) - - # Если пользователь часто открывает кейсы - немного снижаем РПУ - if rpu.total_opened > 50: - rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05) - rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03) - - # Ограничиваем множители + + 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)) + + 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)) + + 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)) + + if rpu.total_opened > 50: + rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05) + rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03) + for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier', 'restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']: value = getattr(rpu, field) setattr(rpu, field, max(0.1, min(3.0, value))) - + rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier)) rpu.last_adjustment = datetime.utcnow() - db.commit() + _rpu_commit_or_flush(db) -def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1): - """Обновляет статистику РПУ после открытия кейсов""" +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() + rpu.total_spent += spent rpu.total_opened += opened - db.commit() - - # Авто-подкрутка если включена + rpu.total_value_received += received_value + rpu.session_spent += spent + rpu.session_won += received_value + + if is_rare or received_value > spent * 3: + rpu.current_streak = min(20, rpu.current_streak + 1) + else: + rpu.current_streak = max(-20, rpu.current_streak - 1) + + if rpu.current_streak > rpu.best_streak: + rpu.best_streak = rpu.current_streak + if rpu.current_streak < rpu.worst_streak: + rpu.worst_streak = rpu.current_streak + + # ── RPU v2: Luck Budget ── + consume_luck_budget(db, user_id, received_value) + regen_luck_budget(db, user_id) + + # ── 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) + + # ── RPU v2: Hot/Cold ── + analyze_hot_cold(db, user_id) + + _rpu_commit_or_flush(db) + if rpu.auto_adjust: - auto_adjust_rpu(db, user_id) \ No newline at end of file + 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() + if not user: + return {} + + ceiling = calculate_ceiling(db, user_id) + inv_value = 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) + + session_count = 0 + if user.created_at: + session_count = max(0, (datetime.utcnow() - user.created_at).days) + + return { + "rpu_level": round(calculate_rpu_level(rpu)), + "description": get_rpu_description(calculate_rpu_level(rpu)), + "luck_multiplier": rpu.luck_multiplier, + "luck_budget": round(rpu.luck_budget, 1), + "ceiling": round(ceiling, 2), + "ceiling_multiplier": round(rpu.ceiling_multiplier, 2), + "ceiling_breach_total": rpu.ceiling_break_count, + "ceiling_breach_session": rpu.ceiling_break_session, + "total_value": round(total_value, 2), + "balance": round(user.balance, 2), + "inventory_value": round(inv_value, 2), + "total_deposited": round(user.total_deposited, 2), + "total_withdrawn": round(user.total_withdrawn, 2), + "session": { + "number": session_count, + "spent": round(rpu.session_spent, 2), + "won": round(rpu.session_won, 2), + }, + "comeback": { + "active": rpu.comeback_active, + "openings_left": rpu.comeback_openings_left, + "multiplier": round(rpu.comeback_multiplier, 2), + }, + "streak": { + "current": rpu.current_streak, + "best": rpu.best_streak, + "worst": rpu.worst_streak, + }, + "hot_cold": { + "is_hot": is_hot, + "is_cold": is_cold, + "hot_score": round(rpu.hot_score, 2), + "withdrawal_ratio": round(wd_ratio, 3), + }, + "promo_phase": get_current_phase(), + } diff --git a/static/js/app.js b/static/js/app.js index e2bc26c..435ba98 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -14,9 +14,10 @@ async function refreshBalance() { const res = await fetch('/web/api/user/balance'); const data = await res.json(); if (data.success) { - document.querySelectorAll('.user-balance').forEach(el => { - el.textContent = `${Math.floor(data.balance)} ₽`; - }); + const el = document.getElementById('siteBalanceDisplay'); + if (el) { + el.innerHTML = Math.floor(data.balance).toLocaleString('ru') + ' ₽ ▼ депозит'; + } } } catch (e) {} } @@ -140,7 +141,7 @@ function showAchievementPopup(title, reward) { } document.getElementById('globalPopupTitle').textContent = title; - document.getElementById('globalPopupReward').textContent = reward ? `+${reward} ₽` : ''; + document.getElementById('globalPopupReward').textContent = ''; popup.style.display = 'block'; if (window.SoundManager) SoundManager.achievement(); diff --git a/static/js/sounds.js b/static/js/sounds.js index 3035950..e4435cb 100644 --- a/static/js/sounds.js +++ b/static/js/sounds.js @@ -1,8 +1,9 @@ -// Sound effects system using Web Audio API - no external files needed const SoundManager = { _ctx: null, _enabled: true, _volume: 0.3, + _activeNodes: new Set(), + _timeouts: [], init() { try { @@ -25,10 +26,28 @@ const SoundManager = { return this._enabled; }, + stopAll() { + this._timeouts.forEach(clearTimeout); + this._timeouts = []; + this._activeNodes.forEach(node => { + try { node.stop(); } catch (e) {} + try { node.disconnect(); } catch (e) {} + }); + this._activeNodes.clear(); + }, + + _trackNode(node, duration) { + this._activeNodes.add(node); + node.addEventListener('ended', () => this._activeNodes.delete(node)); + setTimeout(() => { + this._activeNodes.delete(node); + try { node.disconnect(); } catch (e) {} + }, (duration + 0.1) * 1000); + }, + _play(freq, duration, type = 'sine', volume = 1) { if (!this._enabled || !this._ctx) return; if (this._ctx.state === 'suspended') this._ctx.resume(); - const osc = this._ctx.createOscillator(); const gain = this._ctx.createGain(); osc.type = type; @@ -39,12 +58,13 @@ const SoundManager = { gain.connect(this._ctx.destination); osc.start(); osc.stop(this._ctx.currentTime + duration); + this._trackNode(osc, duration); + this._trackNode(gain, duration); }, _noise(duration, volume = 1) { if (!this._enabled || !this._ctx) return; if (this._ctx.state === 'suspended') this._ctx.resume(); - const bufferSize = this._ctx.sampleRate * duration; const buffer = this._ctx.createBuffer(1, bufferSize, this._ctx.sampleRate); const data = buffer.getChannelData(0); @@ -59,91 +79,113 @@ const SoundManager = { source.connect(gain); gain.connect(this._ctx.destination); source.start(); + this._trackNode(source, duration); + this._trackNode(gain, duration); + }, + + _delay(fn, ms) { + const id = setTimeout(() => { + this._timeouts = this._timeouts.filter(t => t !== id); + fn(); + }, ms); + this._timeouts.push(id); + return id; }, caseOpen() { - // Descending sweep with click this._play(800, 0.1, 'square', 0.3); - setTimeout(() => this._play(400, 0.15, 'sawtooth', 0.2), 80); - setTimeout(() => this._play(200, 0.2, 'sine', 0.15), 180); + this._delay(() => this._play(400, 0.15, 'sawtooth', 0.2), 80); + this._delay(() => this._play(200, 0.2, 'sine', 0.15), 180); this._noise(0.05, 0.4); }, caseRare() { - // Ascending bright chime for rare items this._play(523, 0.15, 'sine', 0.3); - setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 100); - setTimeout(() => this._play(784, 0.15, 'sine', 0.3), 200); - setTimeout(() => this._play(1047, 0.3, 'sine', 0.4), 300); + this._delay(() => this._play(659, 0.15, 'sine', 0.3), 100); + this._delay(() => this._play(784, 0.15, 'sine', 0.3), 200); + this._delay(() => this._play(1047, 0.3, 'sine', 0.4), 300); }, caseCovert() { - // Epic sound for covert/knife this._play(392, 0.2, 'sawtooth', 0.3); - setTimeout(() => this._play(523, 0.2, 'sawtooth', 0.3), 150); - setTimeout(() => this._play(659, 0.2, 'sawtooth', 0.3), 300); - setTimeout(() => this._play(784, 0.4, 'sine', 0.5), 450); - setTimeout(() => this._play(1047, 0.6, 'sine', 0.6), 600); + this._delay(() => this._play(523, 0.2, 'sawtooth', 0.3), 150); + this._delay(() => this._play(659, 0.2, 'sawtooth', 0.3), 300); + this._delay(() => this._play(784, 0.4, 'sine', 0.5), 450); + this._delay(() => this._play(1047, 0.6, 'sine', 0.6), 600); }, slotSpin() { - // Ratcheting sound for slot reels for (let i = 0; i < 8; i++) { - setTimeout(() => { + this._delay(() => { this._play(300 + Math.random() * 400, 0.05, 'square', 0.15); }, i * 80); } }, slotMatch() { - // Win jingle this._play(523, 0.1, 'sine', 0.3); - setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 100); - setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200); - setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 300); + this._delay(() => this._play(659, 0.1, 'sine', 0.3), 100); + this._delay(() => this._play(784, 0.1, 'sine', 0.3), 200); + this._delay(() => this._play(1047, 0.4, 'sine', 0.5), 300); }, contractSubmit() { - // Mechanical sound this._play(150, 0.3, 'sawtooth', 0.2); - setTimeout(() => this._play(200, 0.2, 'square', 0.2), 200); - setTimeout(() => this._play(250, 0.1, 'square', 0.15), 350); + this._delay(() => this._play(200, 0.2, 'square', 0.2), 200); + this._delay(() => this._play(250, 0.1, 'square', 0.15), 350); }, contractResult() { - // Rising success this._play(400, 0.15, 'sine', 0.3); - setTimeout(() => this._play(600, 0.15, 'sine', 0.3), 120); - setTimeout(() => this._play(800, 0.3, 'sine', 0.4), 240); + this._delay(() => this._play(600, 0.15, 'sine', 0.3), 120); + this._delay(() => this._play(800, 0.3, 'sine', 0.4), 240); }, wheelSpin() { - // Ticking as wheel rotates - this._play(200, 0.03, 'square', 0.1); + this._play(600 + Math.random() * 400, 0.04, 'triangle', 0.12); + this._play(200, 0.02, 'square', 0.06); }, wheelWin() { - // Celebration - this._play(523, 0.15, 'sine', 0.3); - setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 120); - setTimeout(() => this._play(784, 0.2, 'sine', 0.35), 240); - setTimeout(() => this._play(1047, 0.5, 'sine', 0.5), 360); + const t = this._ctx.currentTime; + if (!this._enabled || !this._ctx) return; + if (this._ctx.state === 'suspended') this._ctx.resume(); + [523, 659, 784, 1047].forEach((freq, i) => { + const osc = this._ctx.createOscillator(); + const gain = this._ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, t + i * 0.1); + gain.gain.setValueAtTime(0, t + i * 0.1); + gain.gain.linearRampToValueAtTime(this._volume * 0.35, t + i * 0.1 + 0.05); + gain.gain.exponentialRampToValueAtTime(0.001, t + i * 0.1 + 0.5); + osc.connect(gain); + gain.connect(this._ctx.destination); + osc.start(t + i * 0.1); + osc.stop(t + i * 0.1 + 0.5); + this._trackNode(osc, 0.6); + this._trackNode(gain, 0.6); + }); + this._delay(() => this._play(1568, 0.4, 'sine', 0.25), 350); + this._delay(() => this._play(2093, 0.6, 'sine', 0.2), 500); }, wheelLose() { - // Sad trombone - this._play(400, 0.2, 'sine', 0.2); - setTimeout(() => this._play(350, 0.2, 'sine', 0.2), 150); - setTimeout(() => this._play(300, 0.3, 'sine', 0.15), 300); + this._play(300, 0.15, 'sawtooth', 0.25); + this._delay(() => this._play(200, 0.2, 'sawtooth', 0.2), 100); + this._delay(() => this._play(120, 0.3, 'sawtooth', 0.15), 220); + this._noise(0.15, 0.3); + this._delay(() => { + this._play(60, 0.5, 'sine', 0.2); + this._play(45, 0.6, 'sine', 0.15); + }, 350); }, achievement() { - // Achievement unlock this._play(659, 0.1, 'sine', 0.3); - setTimeout(() => this._play(523, 0.1, 'sine', 0.3), 100); - setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200); - setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 300); - setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 400); + this._delay(() => this._play(523, 0.1, 'sine', 0.3), 100); + this._delay(() => this._play(784, 0.1, 'sine', 0.3), 200); + this._delay(() => this._play(659, 0.1, 'sine', 0.3), 300); + this._delay(() => this._play(1047, 0.4, 'sine', 0.5), 400); }, crashBet() { @@ -155,17 +197,15 @@ const SoundManager = { }, crashCrashed() { - // Explosion this._noise(0.3, 0.6); this._play(80, 0.5, 'sawtooth', 0.4); - setTimeout(() => this._play(50, 0.8, 'sine', 0.3), 100); + this._delay(() => this._play(50, 0.8, 'sine', 0.3), 100); }, crashCashout() { - // Cashout success this._play(600, 0.1, 'sine', 0.3); - setTimeout(() => this._play(800, 0.1, 'sine', 0.3), 80); - setTimeout(() => this._play(1000, 0.2, 'sine', 0.4), 160); + this._delay(() => this._play(800, 0.1, 'sine', 0.3), 80); + this._delay(() => this._play(1000, 0.2, 'sine', 0.4), 160); }, click() { @@ -181,5 +221,4 @@ const SoundManager = { } }; -// Auto-init document.addEventListener('DOMContentLoaded', () => SoundManager.init()); diff --git a/templates/_activity_sidebar.html b/templates/_activity_sidebar.html index 9be8bdc..6e49c95 100644 --- a/templates/_activity_sidebar.html +++ b/templates/_activity_sidebar.html @@ -140,6 +140,56 @@ from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } + +/* Иммерсивные эффекты для ленты */ +.asi-effect { + position: relative; + overflow: hidden; +} +.asi-effect::before { + content: attr(data-effect-text); + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + font-size: 2.5rem; + font-weight: 900; + color: currentColor; + opacity: 0.06; + pointer-events: none; + white-space: nowrap; + line-height: 1; +} +.asi-effect-glow { + box-shadow: 0 0 15px 2px rgba(255, 200, 50, 0.15), inset 0 0 20px rgba(255, 200, 50, 0.05); + animation: glowPulse 2s ease-in-out infinite; +} +@keyframes glowPulse { + 0%,100% { box-shadow: 0 0 15px 2px rgba(255,200,50,0.15), inset 0 0 20px rgba(255,200,50,0.05); } + 50% { box-shadow: 0 0 25px 5px rgba(255,200,50,0.25), inset 0 0 30px rgba(255,200,50,0.08); } +} +.asi-effect-shimmer { + position: absolute; + top: 0; left: -100%; + width: 100%; height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.07), transparent); + animation: shimmer 3s ease-in-out infinite; + pointer-events: none; +} +@keyframes shimmer { + 0% { left: -100%; } + 50% { left: 200%; } + 100% { left: -100%; } +} +.asi-effect-badge { + display: inline-block; + font-size: 0.6rem; + font-weight: 700; + padding: 0.1rem 0.4rem; + border-radius: 4px; + margin-top: 0.15rem; +} +.asi-effect-badge.upgrade { background: rgba(139,92,246,0.25); color: #a78bfa; } +.asi-effect-badge.drop { background: rgba(251,191,36,0.25); color: #fbbf24; } .has-activity-sidebar { padding-left: 260px; transition: padding-left 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); @@ -157,9 +207,6 @@

Лента

- - 0 👤 -
@@ -198,11 +245,6 @@ document.addEventListener('DOMContentLoaded', () => { }); } - WS.on('online_count', (data) => { - const el = document.getElementById('onlineCount'); - if (el) el.textContent = data.online || 0; - }); - WS.on('activity', (data) => { const act = data.activity; if (!act || !list) return; @@ -241,8 +283,32 @@ function createActivityItem(act) { const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, ''); const rarityColor = ACTIVITY_RARITY_COLORS[raritySlug] || ''; - if (rarityColor) { - item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`; + + // ── Эффект ── + const effect = act.data && act.data.effect; + let effectBadgeHtml = ''; + if (effect) { + const isUpgrade = effect.startsWith('upgrade:'); + const isDrop = effect.startsWith('drop:'); + const label = effect.replace('upgrade:', '').replace('drop:', ''); + const cls = isUpgrade ? 'upgrade' : 'drop'; + effectBadgeHtml = `${label}`; + item.classList.add('asi-effect'); + item.dataset.effectText = label; + if (isDrop) { + item.classList.add('asi-effect-glow'); + const sh = document.createElement('div'); + sh.className = 'asi-effect-shimmer'; + item.appendChild(sh); + } + if (rarityColor) { + item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.12)`; + item.style.borderLeft = `3px solid ${rarityColor}`; + } + } else { + if (rarityColor) { + item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`; + } } item.addEventListener('click', () => window.location.href = `/profiles/${act.user_id}`); @@ -260,6 +326,7 @@ function createActivityItem(act) { ${escapeHtml(act.username)} ${escapeHtml(act.message)}
${timeStr}
+ ${effectBadgeHtml}
${imgHtml} `; diff --git a/templates/achievements.html b/templates/achievements.html index ed80fc6..530a2ba 100644 --- a/templates/achievements.html +++ b/templates/achievements.html @@ -257,9 +257,6 @@ {% if ach.hidden %}
🤫 Секрет
{% endif %}
{{ ach.title }}
{{ ach.description }}
- {% if ach.reward_amount > 0 %} -
+{{ ach.reward_amount|money }} ₽
- {% endif %}
{ diff --git a/templates/activity.html b/templates/activity.html deleted file mode 100644 index af45b05..0000000 --- a/templates/activity.html +++ /dev/null @@ -1,214 +0,0 @@ - - - - - - Лента активностей - CS2 Simulator - - - - - {% include "_nav.html" %} - -
-
-
-

📰 Лента активностей

-

Что происходит на сервере

-
-
- - Live -
-
- -
- {% if activities %} - {% for a in activities %} -
-
- {% if a.activity_type == 'case_open' %}📦 - {% elif a.activity_type == 'contract' %}🔄 - {% elif a.activity_type == 'upgrade' %}🆙 - {% elif a.activity_type == 'achievement' %}🏆 - {% elif a.activity_type == 'crash' %}💥 - {% else %}📌{% endif %} -
-
-
{{ a.message }}
-
- {{ a.username }} - • {{ a.created_at }} -
-
-
- {% endfor %} - {% else %} -
-
📭
-

Пока нет активностей

-

Открой кейс или выполни контракт — это появится здесь в реальном времени!

-
- {% endif %} -
-
- - - - - - - - - diff --git a/templates/admin/base.html b/templates/admin/base.html index 774b9a3..72b95f5 100644 --- a/templates/admin/base.html +++ b/templates/admin/base.html @@ -26,6 +26,8 @@ flex-direction: column; position: sticky; top: 0; height: 100vh; flex-shrink: 0; + z-index: 50; + transition: transform 0.25s ease; } .admin-sidebar .sb-brand { padding: 1.25rem 1.25rem 0.75rem; @@ -73,6 +75,28 @@ } .admin-sidebar .sb-footer a:hover { color: #f59e0b; } + /* ─── Sidebar overlay for mobile ─── */ + .sidebar-overlay { + display: none; + position: fixed; inset: 0; + background: rgba(0,0,0,0.5); + z-index: 49; + } + .sidebar-overlay.open { display: block; } + + /* ─── Hamburger ─── */ + .hamburger { + display: none; + background: none; + border: none; + color: rgba(255,255,255,0.6); + font-size: 1.3rem; + cursor: pointer; + padding: 0.25rem; + line-height: 1; + } + .hamburger:hover { color: #fff; } + /* ─── Main area ─── */ .admin-main { flex: 1; @@ -85,8 +109,10 @@ padding: 0.85rem 1.5rem; background: rgba(17,18,22,0.8); backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255,255,255,0.04); position: sticky; top: 0; z-index: 10; + gap: 0.75rem; } .admin-topbar .at-title { font-size: 1rem; font-weight: 600; @@ -96,7 +122,10 @@ font-size: 0.75rem; color: rgba(255,255,255,0.35); font-weight: 400; } - .admin-topbar .at-actions { display: flex; align-items: center; gap: 0.75rem; } + .admin-topbar .at-actions { + display: flex; align-items: center; gap: 0.75rem; + margin-left: auto; + } .admin-topbar .at-actions .at-back { color: rgba(255,255,255,0.5); text-decoration: none; font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem; @@ -152,6 +181,7 @@ .a-stat .as-value { font-size: 1.8rem; font-weight: 700; line-height: 1.1; + word-break: break-word; } .a-stat .as-bar { position: absolute; bottom: 0; left: 0; height: 2px; @@ -196,6 +226,11 @@ flex-wrap: wrap; } + /* ─── Responsive tables: card view on mobile ─── */ + .a-table-card-view { + display: none; + } + /* ─── Buttons ─── */ .a-btn { display: inline-flex; align-items: center; gap: 0.35rem; @@ -255,6 +290,7 @@ color: rgba(255,255,255,0.5); margin-bottom: 0.35rem; } + select.a-input { cursor: pointer; } /* ─── Badge ─── */ .a-badge { @@ -272,6 +308,7 @@ .a-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); z-index: 100; display: none; align-items: center; justify-content: center; padding: 1rem; } @@ -283,13 +320,16 @@ padding: 1.5rem; max-width: 460px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.5); + max-height: 90vh; overflow-y: auto; } + .a-modal-wide { max-width: 700px; } .a-modal-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; } .a-modal-actions { display: flex; gap: 0.5rem; margin-top: 1rem; justify-content: flex-end; + flex-wrap: wrap; } /* ─── Grid helpers ─── */ @@ -298,7 +338,14 @@ .a-gap-sm { gap: 0.5rem; } .a-mt { margin-top: 1rem; } .a-mb { margin-bottom: 1rem; } - .a-flex { display: flex; align-items: center; gap: 0.5rem; } + .a-flex { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } + + /* ─── Search in top bar ─── */ + .search-form { + display: flex; gap: 0.5rem; align-items: center; + } + .search-form .a-input { width: 200px; } + .search-form .a-btn { flex-shrink: 0; } /* ─── Empty state ─── */ .a-empty { @@ -307,20 +354,86 @@ } .a-empty .a-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; } - @media(max-width:768px){ - .admin-sidebar { width: 56px; } - .admin-sidebar .sb-brand span, - .admin-sidebar .sb-nav a span:not(.sb-icon), - .admin-sidebar .sb-footer span { display: none; } - .admin-sidebar .sb-nav a { justify-content: center; padding: 0.65rem; } - .admin-sidebar .sb-footer a { justify-content: center; } + /* ─── Responsive ─── */ + @media(max-width: 1024px){ + .admin-sidebar { width: 200px; } + } + + @media(max-width: 768px){ + .admin-sidebar { + position: fixed; left: 0; top: 0; + transform: translateX(-100%); + width: 260px; + box-shadow: 4px 0 30px rgba(0,0,0,0.4); + } + .admin-sidebar.open { transform: translateX(0); } + .admin-content { padding: 1rem; } + .admin-topbar { padding: 0.7rem 1rem; } + .admin-topbar .at-title .at-sub { display: none; } + + .hamburger { display: inline-flex; } + + .a-stats { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + } + .a-stat { padding: 1rem; } + .a-stat .as-value { font-size: 1.4rem; } + + .search-form .a-input { width: 140px; } + + .a-modal { margin: 0.5rem; padding: 1rem; border-radius: 12px; } + + /* Card-style table on mobile */ + .a-table { display: none; } + .a-table-card-view { display: flex; flex-direction: column; gap: 0.75rem; } + .a-table-card { + background: rgba(17,18,22,0.6); + border: 1px solid rgba(255,255,255,0.05); + border-radius: 10px; + padding: 0.85rem; + font-size: 0.82rem; + } + .a-table-card .tc-row { + display: flex; justify-content: space-between; + padding: 0.25rem 0; + gap: 0.5rem; + } + .a-table-card .tc-row .tc-label { + color: rgba(255,255,255,0.35); + font-size: 0.72rem; + flex-shrink: 0; + } + .a-table-card .tc-row .tc-value { + text-align: right; + word-break: break-word; + } + .a-table-card .tc-actions { + margin-top: 0.5rem; padding-top: 0.5rem; + border-top: 1px solid rgba(255,255,255,0.04); + display: flex; gap: 0.35rem; flex-wrap: wrap; + } + } + + @media(max-width: 480px){ + html { font-size: 14px; } + .admin-content { padding: 0.75rem; } + .admin-topbar { padding: 0.6rem 0.75rem; } + .a-stats { grid-template-columns: 1fr 1fr; gap: 0.5rem; } + .a-stat { padding: 0.75rem; } + .a-stat .as-value { font-size: 1.2rem; } + .a-card { padding: 0.85rem; } + .search-form { width: 100%; } + .search-form .a-input { width: 100%; } } {% block extra_styles %}{% endblock %} -
+ + +
CS2 Admin @@ -344,6 +457,7 @@
+ {% block page_title %}Панель управления{% endblock %}
@@ -357,6 +471,34 @@ {% block modals %}{% endblock %} + {% block scripts %}{% endblock %} diff --git a/templates/admin/cases.html b/templates/admin/cases.html index 8f523e1..9284d33 100644 --- a/templates/admin/cases.html +++ b/templates/admin/cases.html @@ -53,6 +53,32 @@ {% endfor %} +
+ {% for c in cases %} +
+
+ Название + {{ c.name }} +
+
+ Цена + {{ c.price_open }} ₽ +
+
+ Тип + {{ c.display_name|default('') }} +
+
+ Предметов + {{ c.items_count }} +
+
+ + +
+
+ {% endfor %} +
{% endblock %} @@ -60,57 +86,90 @@ {% block modals %}
-
+
🎲 Создать кейс
-
-
- - +
+
+
+ + +
+
+ + +
-
- - +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
📋 Предметы кейса (0)
+ + +
+ + +
+ + + + +
+
+ + + + +
+ + +
-
-
- - -
-
- - -
-
-
- - -
-
-
📋 Предметы кейса
-
-
- - -
-
-
+
- -
-
-
📦 Все предметы
- -
-
-
-
@@ -145,13 +204,26 @@ {% block extra_styles %} {% endblock %} @@ -159,17 +231,42 @@ +{% endblock %} diff --git a/templates/admin/user_detail.html b/templates/admin/user_detail.html index 94b7563..4c38c2f 100644 --- a/templates/admin/user_detail.html +++ b/templates/admin/user_detail.html @@ -8,10 +8,11 @@ {% endblock %} {% block top_actions %} ← Назад + 📜 История {% endblock %} {% block content %} -
+
Баланс
{{ target_user.balance|money }} ₽
@@ -49,6 +50,26 @@
+ +
+
🎲 RPU v2 — подробно
+
+
+
--
Потолок / Депнуто
+
--
Luck Budget
+
--
Сессия потрачено
+
--
Hot Score
+
--
Промо-фаза
+
+
+
--
Комбек
+
--
Пробитий
+
--
Проигрышей подряд
+
--
Сброс сессии
+
+
+
+
🔧 Действия
@@ -84,6 +105,27 @@ {% endfor %} +
+ {% for item in inventory %} +
+
+ ID + {{ item.id }} +
+
+ Название + {{ item.market_hash_name[:40] }}{{'…' if item.market_hash_name|length > 40}} +
+
+ Редкость / Float + {{ item.rarity }} / {{ "%.4f"|format(item.float_value) }} +
+
+ +
+
+ {% endfor %} +
{% endblock %} @@ -251,6 +293,24 @@ async function loadRPU() { document.getElementById('rpuSpent').textContent = Math.round(d.stats.total_spent).toLocaleString() + ' ₽'; document.getElementById('rpuOpened').textContent = d.stats.total_opened.toLocaleString(); document.getElementById('rpuAutoStatus').textContent = d.auto_adjust ? '✅' : '❌'; + + // ── RPU v2 ── + if (d.v2) { + const v = d.v2; + document.getElementById('v2Ceiling').textContent = Math.round(v.ceiling.current_ceiling).toLocaleString('ru') + '₽ / ' + Math.round(v.ceiling.total_deposited).toLocaleString('ru') + '₽'; + document.getElementById('v2Budget').textContent = v.session.luck_budget + '%'; + document.getElementById('v2Budget').style.color = v.session.luck_budget < 30 ? '#ef4444' : v.session.luck_budget > 70 ? '#22c55e' : '#f59e0b'; + document.getElementById('v2SessionSpent').textContent = Math.round(v.session.session_spent).toLocaleString('ru') + '₽'; + document.getElementById('v2HotScore').textContent = v.hot_cold.hot_score; + document.getElementById('v2HotScore').style.color = v.hot_cold.hot_score > 0.5 ? '#22c55e' : v.hot_cold.hot_score < -0.5 ? '#ef4444' : '#f59e0b'; + const ph = v.promo_phase; + document.getElementById('v2PromoPhase').textContent = ph.phase; + document.getElementById('v2PromoPhase').style.color = ph.phase === 'LUCKY' ? '#22c55e' : ph.phase === 'UNLUCKY' ? '#ef4444' : '#f59e0b'; + document.getElementById('v2Comeback').textContent = v.comeback.active ? '✅ x' + v.comeback.multiplier.toFixed(1) + ' (' + v.comeback.openings_left + ')' : '❌'; + document.getElementById('v2BreakCount').textContent = v.ceiling.break_count + ' (' + v.ceiling.break_session + ' сесс)'; + document.getElementById('v2LossStreak').textContent = v.comeback.consecutive_loss_count + ' (' + Math.round(v.comeback.consecutive_loss_value).toLocaleString('ru') + '₽)'; + document.getElementById('v2SessionReset').textContent = v.session.reset_date ? new Date(v.session.reset_date).toLocaleString('ru') : '—'; + } } catch(_) {} } diff --git a/templates/admin/user_history.html b/templates/admin/user_history.html new file mode 100644 index 0000000..12752fd --- /dev/null +++ b/templates/admin/user_history.html @@ -0,0 +1,122 @@ +{% extends "admin/base.html" %} +{% block title %}История {{ target_user.username }} — Админ-панель{% endblock %} +{% block nav_users %}active{% endblock %} +{% block page_title %} + 📜 История {{ target_user.username }} + ID {{ target_user.id }} +{% endblock %} +{% block top_actions %} + ← Профиль +{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
Потолок
{{ "%.0f"|format(ceiling) }} ₽
+
Текущая ценность
{{ "%.0f"|format(total_value) }} ₽
+
Luck budget
{{ "%.1f"|format(rpu.luck_budget) }}%
+
Hot score
{{ "%.0f"|format(rpu.hot_score or 0) }}
+
Ceiling mult
{{ "%.2f"|format(rpu.ceiling_multiplier) }}
+
Комбек
{{ rpu.comeback_active }}
+
+ +
+ + + +
+ +
+
+ + + + {% for o in openings %} + + + + + + + + + {% else %} + + {% endfor %} + +
IDДатаКейсПредметРедкостьFloat
{{ o.id }}{{ o.opened_at.strftime('%d.%m %H:%M') }}{{ o.case_name }}{{ o.item_name }}{{ o.rarity }}{{ "%.4f"|format(o.float_value) if o.float_value else '—' }}
Нет открытий
+
+
+ + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/admin/users.html b/templates/admin/users.html index fd6e579..afac16c 100644 --- a/templates/admin/users.html +++ b/templates/admin/users.html @@ -3,8 +3,8 @@ {% block nav_users %}active{% endblock %} {% block page_title %}👥 Пользователи{% endblock %} {% block top_actions %} -
- +
+
{% endblock %} @@ -50,6 +50,40 @@ {% endfor %} + +
+ {% for u in users %} +
+
+ ID + {{ u.id }} +
+
+ Имя + {{ u.username }} +
+
+ Баланс + {{ u.balance|money }} ₽ +
+
+ Статус + + {% if u.is_admin %}Админ{% endif %} + {% if u.is_banned %}Забанен{% endif %} + {% if not u.is_admin and not u.is_banned %}Игрок{% endif %} + +
+
+ Кейсов / Контр / Предм + {{ u.case_openings }} / {{ u.contracts }} / {{ u.inventory_count }} +
+ +
+ {% endfor %} +
{% else %}
diff --git a/templates/case_detail.html b/templates/case_detail.html index 16d4365..3b4391d 100644 --- a/templates/case_detail.html +++ b/templates/case_detail.html @@ -519,9 +519,10 @@ const data = await response.json(); if (data.success) { userBalance = data.balance; - document.querySelectorAll('.user-balance').forEach(el => { - el.textContent = `${Math.floor(data.balance)} ₽`; - }); + const el = document.getElementById('siteBalanceDisplay'); + if (el) { + el.innerHTML = Math.floor(data.balance).toLocaleString('ru') + ' ₽ ▼ депозит'; + } updatePriceDisplay(); } } catch (e) { @@ -1091,7 +1092,7 @@ const inventoryIds = []; for (const trashItem of trashItems) { const invItem = items.find(i => i.item_id === trashItem.id && - Math.abs(i.float - trashItem.float) < 0.001); + Math.abs(i.float - trashItem.float) < 0.01); if (invItem) { inventoryIds.push(invItem.id); } @@ -1101,10 +1102,28 @@ const formData = new FormData(); formData.append('inventory_ids', JSON.stringify(inventoryIds)); - await fetch('/web/api/inventory/sell/bulk', { + const res = await fetch('/web/api/inventory/sell/bulk', { method: 'POST', body: formData }); + if (!res.ok) { + await new Promise(r => setTimeout(r, 500)); + const retry = await fetch('/web/api/inventory/items'); + const items2 = await retry.json(); + const retryIds = []; + for (const trashItem of trashItems) { + const invItem = items2.find(i => i.item_id === trashItem.id && + Math.abs(i.float - trashItem.float) < 0.01); + if (invItem && !inventoryIds.includes(invItem.id)) { + retryIds.push(invItem.id); + } + } + if (retryIds.length > 0) { + const fd2 = new FormData(); + fd2.append('inventory_ids', JSON.stringify(retryIds)); + await fetch('/web/api/inventory/sell/bulk', { method: 'POST', body: fd2 }); + } + } } } catch (e) { console.error('Ошибка продажи шлака:', e); @@ -1702,7 +1721,7 @@
${item.count > 1 ? `x${item.count}` : ''} @@ -1724,13 +1743,14 @@ }); } - async function sellResultItem(itemId) { + async function sellResultItem(itemId, itemFloat) { Notify.confirm('Продать этот предмет?', 'Продажа', async function(confirmed) { if (!confirmed) return; try { const response = await fetch('/web/api/inventory/items'); const items = await response.json(); - const inventoryItem = items.find(i => i.item_id === itemId); + const inventoryItem = items.find(i => i.item_id === itemId && + Math.abs(i.float - (itemFloat || 0)) < 0.001); if (!inventoryItem) { Notify.error('Предмет не найден в инвентаре'); @@ -1865,8 +1885,7 @@ if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) { data.achievements_unlocked.forEach(ach => { const title = ach.title || ach.name || 'Достижение'; - const reward = ach.reward || ''; - showAchievementPopup(title, reward); + showAchievementPopup(title); }); } } diff --git a/templates/cases.html b/templates/cases.html index 08f2b2c..c3c2f58 100644 --- a/templates/cases.html +++ b/templates/cases.html @@ -152,6 +152,85 @@ .btn-open:hover { background: var(--primary-hover); } + + .promo-wrapper { + background: linear-gradient(135deg, #1a1a2e 0%, #2d1b3d 50%, #1a1a2e 100%); + border: 1px solid rgba(255,215,0,0.2); + border-radius: 12px; + margin-bottom: 1.5rem; + padding: 0.75rem 1rem; + box-shadow: 0 0 20px rgba(255,215,0,0.05); + } + .promo-container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + } + .promo-info { display: flex; align-items: center; gap: 0.75rem; } + .promo-info--card { text-align: center; } + .promo-info--label { + font-size: 1.6rem; + font-weight: 700; + color: #ffd700; + line-height: 1; + } + .promo-info--label-bottom { + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-dim); + letter-spacing: 0.05em; + } + .promo-timer--inner { display: flex; align-items: center; gap: 0.5rem; } + .promo-timer--label { + font-size: 0.7rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + } + .promo-timer--countdown { + font-size: 1.1rem; + font-weight: 700; + color: #ffd700; + font-variant-numeric: tabular-nums; + } + .promo-code { display: flex; align-items: center; gap: 0.5rem; } + .promo-code--label { + font-size: 0.65rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + } + .promo-code--label-light { color: #ffd700; font-weight: 600; } + .promo-code--button { + background: linear-gradient(135deg, #ffd700, #f59e0b); + border: none; + border-radius: 8px; + padding: 0.4rem 1rem; + font-weight: 700; + font-size: 0.9rem; + cursor: pointer; + color: #1a1a2e; + transition: transform 0.15s, box-shadow 0.15s; + position: relative; + } + .promo-code--button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(255,215,0,0.4); + } + .promo-code--button:active { transform: translateY(0); } + .promo-code--button .inner { position: relative; z-index: 2; } + .promo-code--button.copied { + background: linear-gradient(135deg, #16a34a, #15803d); + color: white; + } + @media (max-width: 640px) { + .promo-container { flex-direction: column; align-items: stretch; text-align: center; } + .promo-info { justify-content: center; } + .promo-timer--inner { justify-content: center; } + .promo-code { justify-content: center; flex-wrap: wrap; } + } @@ -162,6 +241,33 @@

📦 Кейсы

Выберите кейс для открытия

+ {% if auto_promo and user %} +
+
+
+
+
+{{ auto_promo.reward_amount }}%
+
НА СЧЕТ
+
+
+
+
+

ОСТАЛОСЬ ВРЕМЕНИ:

+ + 00:00:00 + +
+
+
+

ИСПОЛЬЗУЙ ПРОМОКОД:

+ +
+
+
+ {% endif %} +
{% for section in sections %}
@@ -207,6 +313,47 @@
+