RPU v2, promo/bank система, админка, оптимизации
— RPU v2: бюджет удачи, потолок (ceiling), комбек после проигрышей, hot/cold анализ, интеграция с promo-фазами — Bank API: mock-банк для карты (депозит/вывод с балансом 1000₽) — Promo-сервис: автогенерация промокодов каждый час, типы (card_to_site, luck_boost, ceiling_boost, free_case, reset_streak) — Promo-фазы: LUCKY/NEUTRAL/UNLUCKY, переключение каждые 1-6ч — Admin: история транзакций (user_history), управление промокодами, управление promo-фазами, API фильтров/коллекций, RPU info — Database: WAL mode, новые поля RPU v2, TransactionLog, карточный баланс, статистики (streaks, ceiling, budget) — Frontend: /deposit, /promo/activate, /withdraw, /card-balance, /online-count, /rpu-info, lifespan вместо startup event — UI: user_history.html, улучшения профиля, кейсов, навигации — Cases: обновление cases.json (1892 строки новых данных) — Achievements: кэш коллекций, оптимизация is_custom проверок — Sounds: дополнительные звуковые эффекты — Upgrade: интеграция с RPU (множители, streaks) — .gitignore: исключены .db, .bak, __pycache__
This commit is contained in:
+4
-3
@@ -1,6 +1,7 @@
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.bak
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
cs2_simulator.db
|
||||
*.log
|
||||
|
||||
+165
-186
@@ -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
|
||||
# Коллекции пользователя
|
||||
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 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", ""))
|
||||
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
|
||||
|
||||
# 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):
|
||||
for coll_name in coll_base_names:
|
||||
if coll_name.startswith("Case:"):
|
||||
owned = user_coll_base_names.get(coll_name, set())
|
||||
if coll_base_names[coll_name].issubset(owned):
|
||||
r = check_achievement(db, user_id, "coll_full_case", 1)
|
||||
if r: results.append(r)
|
||||
break
|
||||
# Also check non-case named collections
|
||||
for coll_name, all_bases in all_coll_base_names.items():
|
||||
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
|
||||
|
||||
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,105 +2263,112 @@ 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:
|
||||
# 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)
|
||||
break
|
||||
|
||||
# Dildo all rarities
|
||||
# Dildo rarities & unique patterns & total value — из dildo_inv (уже загружено)
|
||||
if dildo_inv:
|
||||
dildo_rarities = set()
|
||||
for inv_item, item_data in dildo_items:
|
||||
unique_patterns = set()
|
||||
dildo_total = 0
|
||||
for inv_item, item_data in dildo_inv:
|
||||
dildo_rarities.add(item_data.get("rarity"))
|
||||
if len(dildo_rarities) >= 5: # Consumer, Industrial, Mil-Spec, Restricted, Classified, Covert
|
||||
unique_patterns.add(item_data.get("market_hash_name", ""))
|
||||
dildo_total += item_data.get("price_rub", 0)
|
||||
|
||||
if len(dildo_rarities) >= 5:
|
||||
r = check_achievement(db, user_id, "dildo_all_rarities", 1)
|
||||
if r: results.append(r)
|
||||
|
||||
# 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 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:
|
||||
# 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
|
||||
@@ -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 = []
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from database import get_db, User, InventoryItem, CaseOpening, Contract
|
||||
from database import get_db, User, InventoryItem, CaseOpening, Contract, Upgrade, TransactionLog
|
||||
from auth import get_current_admin
|
||||
from rpu import get_user_rpu, calculate_rpu_level, get_rpu_description
|
||||
|
||||
@@ -184,6 +184,48 @@ async def admin_user_detail(
|
||||
"inventory": inventory
|
||||
})
|
||||
|
||||
@admin_router.get("/user/{user_id}/history", response_class=HTMLResponse)
|
||||
async def admin_user_history(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Все открытия кейсов
|
||||
openings = db.query(CaseOpening).filter(
|
||||
CaseOpening.user_id == user_id
|
||||
).order_by(desc(CaseOpening.opened_at)).limit(200).all()
|
||||
|
||||
# Все апгрейды
|
||||
upgrades = db.query(Upgrade).filter(
|
||||
Upgrade.user_id == user_id
|
||||
).order_by(desc(Upgrade.created_at)).limit(200).all()
|
||||
|
||||
# Все транзакции
|
||||
transactions = db.query(TransactionLog).filter(
|
||||
TransactionLog.user_id == user_id
|
||||
).order_by(desc(TransactionLog.created_at)).limit(200).all()
|
||||
|
||||
from rpu import calculate_ceiling, get_user_total_value, get_user_rpu
|
||||
ceiling = calculate_ceiling(db, user_id)
|
||||
total_value = get_user_total_value(db, user_id)
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
|
||||
return templates.TemplateResponse(request, "admin/user_history.html", {
|
||||
"user": admin,
|
||||
"target_user": target_user,
|
||||
"openings": openings,
|
||||
"upgrades": upgrades,
|
||||
"transactions": transactions,
|
||||
"ceiling": ceiling,
|
||||
"total_value": total_value,
|
||||
"rpu": rpu,
|
||||
})
|
||||
|
||||
@admin_router.get("/cases", response_class=HTMLResponse)
|
||||
async def admin_cases(
|
||||
request: Request,
|
||||
@@ -197,6 +239,7 @@ async def admin_cases(
|
||||
for case in cases:
|
||||
if not isinstance(case.get('items'), list):
|
||||
case['items'] = []
|
||||
case['items_count'] = len(case['items'])
|
||||
|
||||
all_items = []
|
||||
for i, item in enumerate(ALL_ITEMS[:500]):
|
||||
@@ -272,7 +315,7 @@ async def admin_give_item(
|
||||
if not item_data:
|
||||
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
|
||||
|
||||
item_float = generate_item_float(item_data.get("rarity", "Unknown"))
|
||||
item_float = item_data.get("mid_float_used") or generate_item_float(item_data.get("rarity", "Unknown"))
|
||||
|
||||
inventory_item = InventoryItem(
|
||||
user_id=user_id,
|
||||
@@ -282,6 +325,8 @@ async def admin_give_item(
|
||||
wear=item_data.get("wear", "Unknown"),
|
||||
float_value=item_float,
|
||||
type=item_data.get("type", "Normal"),
|
||||
image_url=item_data.get("image_url", ""),
|
||||
price_rub=item_data.get("price_rub", 0),
|
||||
obtained_from="admin"
|
||||
)
|
||||
db.add(inventory_item)
|
||||
@@ -370,6 +415,13 @@ async def admin_get_user_rpu(
|
||||
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
|
||||
from promo_phase import get_promo_phase_info
|
||||
promo = get_promo_phase_info()
|
||||
|
||||
# Потолок
|
||||
from rpu import calculate_ceiling
|
||||
ceiling = calculate_ceiling(db, user_id)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"username": target_user.username,
|
||||
@@ -387,7 +439,44 @@ async def admin_get_user_rpu(
|
||||
"rpu_level": calculate_rpu_level(rpu),
|
||||
"stats": {
|
||||
"total_spent": rpu.total_spent,
|
||||
"total_opened": rpu.total_opened
|
||||
"total_opened": rpu.total_opened,
|
||||
"total_value_received": rpu.total_value_received,
|
||||
"current_streak": rpu.current_streak,
|
||||
"best_streak": rpu.best_streak,
|
||||
"worst_streak": rpu.worst_streak
|
||||
},
|
||||
# ── RPU v2 ──
|
||||
"v2": {
|
||||
"ceiling": {
|
||||
"total_deposited": target_user.total_deposited or 0,
|
||||
"ceiling_multiplier": rpu.ceiling_multiplier,
|
||||
"current_ceiling": ceiling,
|
||||
"break_count": rpu.ceiling_break_count,
|
||||
"break_session": rpu.ceiling_break_session,
|
||||
},
|
||||
"session": {
|
||||
"luck_budget": round(rpu.luck_budget, 1),
|
||||
"session_spent": round(rpu.session_spent, 0),
|
||||
"session_won": round(rpu.session_won, 0),
|
||||
"reset_date": rpu.session_reset_date.isoformat() if rpu.session_reset_date else None,
|
||||
},
|
||||
"comeback": {
|
||||
"active": rpu.comeback_active,
|
||||
"openings_left": rpu.comeback_openings_left,
|
||||
"multiplier": rpu.comeback_multiplier,
|
||||
"consecutive_loss_value": round(rpu.consecutive_loss_value or 0, 0),
|
||||
"consecutive_loss_count": rpu.consecutive_loss_count or 0,
|
||||
},
|
||||
"hot_cold": {
|
||||
"hot_score": round(rpu.hot_score or 0, 2),
|
||||
"last_activity": rpu.last_activity_date.isoformat() if rpu.last_activity_date else None,
|
||||
},
|
||||
"promo_phase": {
|
||||
"phase": promo["phase"],
|
||||
"effect": promo["effect"],
|
||||
"next_switch": promo["next_switch"],
|
||||
"remaining_minutes": promo["remaining_minutes"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,30 +570,30 @@ async def admin_set_rpu_preset(
|
||||
|
||||
@admin_router.post("/api/cases/create")
|
||||
async def admin_create_case(
|
||||
name: str = Form(...),
|
||||
case_name: str = Form(...),
|
||||
display_name: str = Form(...),
|
||||
price: float = Form(...),
|
||||
price_open: float = Form(...),
|
||||
description: str = Form(""),
|
||||
image_url: str = Form(""),
|
||||
items: str = Form(...),
|
||||
items_json: str = Form(...),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
cases = load_cases_config()
|
||||
|
||||
for case in cases:
|
||||
if case['name'] == name:
|
||||
if case['name'] == case_name:
|
||||
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
|
||||
|
||||
try:
|
||||
item_ids = json.loads(items)
|
||||
parsed_items = json.loads(items_json)
|
||||
except:
|
||||
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
|
||||
|
||||
new_case = {
|
||||
"name": name,
|
||||
"name": case_name,
|
||||
"display_name": display_name,
|
||||
"price_open": price,
|
||||
"items": item_ids,
|
||||
"price_open": price_open,
|
||||
"items": parsed_items,
|
||||
"image_url": image_url,
|
||||
"description": description
|
||||
}
|
||||
@@ -522,10 +611,11 @@ async def admin_create_case(
|
||||
async def admin_update_case(
|
||||
case_name: str,
|
||||
display_name: str = Form(...),
|
||||
price: float = Form(...),
|
||||
price_open: float = Form(...),
|
||||
description: str = Form(""),
|
||||
image_url: str = Form(""),
|
||||
items: str = Form(...),
|
||||
items_json: str = Form(...),
|
||||
new_name: str = Form(""),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
cases = load_cases_config()
|
||||
@@ -533,18 +623,34 @@ async def admin_update_case(
|
||||
for case in cases:
|
||||
if case['name'] == case_name:
|
||||
try:
|
||||
item_ids = json.loads(items)
|
||||
parsed_items = json.loads(items_json)
|
||||
except:
|
||||
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
|
||||
|
||||
target_name = new_name or case_name
|
||||
|
||||
# Проверяем конфликт имени при переименовании
|
||||
if new_name and new_name != case_name:
|
||||
for c in cases:
|
||||
if c['name'] == new_name:
|
||||
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
|
||||
case['name'] = new_name
|
||||
|
||||
case['display_name'] = display_name
|
||||
case['price_open'] = price
|
||||
case['price_open'] = price_open
|
||||
case['description'] = description
|
||||
case['image_url'] = image_url
|
||||
case['items'] = item_ids
|
||||
case['items'] = parsed_items
|
||||
|
||||
save_cases_config(cases)
|
||||
|
||||
# Обновляем mainpage если переименован
|
||||
if new_name and new_name != case_name:
|
||||
mainpage = load_mainpage_config()
|
||||
for section in mainpage:
|
||||
section['cases'] = [new_name if c == case_name else c for c in section['cases']]
|
||||
save_mainpage_config(mainpage)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"case": case,
|
||||
@@ -594,57 +700,90 @@ async def admin_search_items(
|
||||
q: str = "",
|
||||
min_price: float = 0,
|
||||
max_price: float = 0,
|
||||
limit: int = 200
|
||||
limit: int = 200,
|
||||
rarity_filter: str = "",
|
||||
collection_filter: str = "",
|
||||
type_filter: str = "",
|
||||
weapon_filter: str = "",
|
||||
sort_by: str = "",
|
||||
sort_order: str = "asc",
|
||||
include_subvariants: bool = False
|
||||
):
|
||||
"""Поиск предметов для добавления в кейс / апгрейда"""
|
||||
"""Поиск предметов для добавления в кейс / апгрейда."""
|
||||
from frontend import ALL_ITEMS
|
||||
from backend import COLLECTIONS_INDEX
|
||||
|
||||
query = q.lower().strip()
|
||||
|
||||
results = []
|
||||
seen_bases = set()
|
||||
|
||||
def accept_item(item, idx):
|
||||
if not include_subvariants and item.get("is_subvariant"):
|
||||
return False
|
||||
if min_price > 0 and item.get("price_rub", 0) < min_price:
|
||||
return False
|
||||
if max_price > 0 and item.get("price_rub", 0) > max_price:
|
||||
return False
|
||||
if rarity_filter:
|
||||
item_rarity = item.get("rarity", "").lower()
|
||||
if rarity_filter.lower() not in item_rarity:
|
||||
return False
|
||||
if collection_filter:
|
||||
item_coll = item.get("collection", "").lower()
|
||||
if collection_filter.lower() not in item_coll:
|
||||
return False
|
||||
if type_filter:
|
||||
item_type = item.get("type", "").lower()
|
||||
if type_filter.lower() != item_type:
|
||||
return False
|
||||
if weapon_filter:
|
||||
item_weapon = item.get("weapon", "").lower()
|
||||
if weapon_filter.lower() != item_weapon:
|
||||
return False
|
||||
return True
|
||||
|
||||
if not query or len(query) < 2:
|
||||
# Без поиска возвращаем разнообразные предметы со всего ассортимента
|
||||
# Если есть ценовой фильтр — проходим по всем, иначе шагами
|
||||
if min_price > 0 or max_price > 0:
|
||||
for idx, item in enumerate(ALL_ITEMS):
|
||||
if accept_item(item, idx):
|
||||
item_id = item.get("_id", idx)
|
||||
results.append({
|
||||
"id": item_id,
|
||||
"name": item.get("market_hash_name", "Unknown"),
|
||||
def make_result(item, idx):
|
||||
name = item.get("market_hash_name", "Unknown")
|
||||
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
|
||||
if dedup_key in seen_bases:
|
||||
return None
|
||||
seen_bases.add(dedup_key)
|
||||
return {
|
||||
"id": item.get("_id", idx),
|
||||
"name": name,
|
||||
"rarity": item.get("rarity", "Unknown"),
|
||||
"image_url": item.get("image_url", ""),
|
||||
"price_rub": item.get("price_rub", 0),
|
||||
"weapon": item.get("weapon", ""),
|
||||
"pattern": item.get("pattern", "")
|
||||
})
|
||||
"pattern": item.get("pattern", ""),
|
||||
"collection": item.get("collection", ""),
|
||||
"type": item.get("type", "Normal"),
|
||||
}
|
||||
|
||||
if query and len(query) < 2:
|
||||
return []
|
||||
|
||||
if not query or len(query) < 2:
|
||||
if any([min_price > 0, max_price > 0, rarity_filter, collection_filter,
|
||||
type_filter, weapon_filter]):
|
||||
for idx, item in enumerate(ALL_ITEMS):
|
||||
if not accept_item(item, idx):
|
||||
continue
|
||||
r = make_result(item, idx)
|
||||
if r:
|
||||
results.append(r)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
else:
|
||||
step = max(1, len(ALL_ITEMS) // limit)
|
||||
for idx in range(0, len(ALL_ITEMS), step):
|
||||
if not accept_item(ALL_ITEMS[idx], idx):
|
||||
continue
|
||||
item = ALL_ITEMS[idx]
|
||||
item_id = item.get("_id", idx)
|
||||
results.append({
|
||||
"id": item_id,
|
||||
"name": item.get("market_hash_name", "Unknown"),
|
||||
"rarity": item.get("rarity", "Unknown"),
|
||||
"image_url": item.get("image_url", ""),
|
||||
"price_rub": item.get("price_rub", 0),
|
||||
"weapon": item.get("weapon", ""),
|
||||
"pattern": item.get("pattern", "")
|
||||
})
|
||||
if len(results) >= 200:
|
||||
if not accept_item(item, idx):
|
||||
continue
|
||||
r = make_result(item, idx)
|
||||
if r:
|
||||
results.append(r)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
else:
|
||||
for idx, item in enumerate(ALL_ITEMS):
|
||||
@@ -653,22 +792,111 @@ async def admin_search_items(
|
||||
name = item.get("market_hash_name", "").lower()
|
||||
weapon = item.get("weapon", "").lower()
|
||||
pattern = item.get("pattern", "").lower()
|
||||
coll = item.get("collection", "").lower()
|
||||
|
||||
if query in name or query in weapon or query in pattern:
|
||||
item_id = item.get("_id", idx)
|
||||
if query in name or query in weapon or query in pattern or query in coll:
|
||||
r = make_result(item, idx)
|
||||
if r:
|
||||
results.append(r)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
# Сортировка
|
||||
if sort_by in ("price_rub", "name", "rarity"):
|
||||
reverse = sort_order == "desc"
|
||||
if sort_by == "rarity":
|
||||
rarity_rank = {
|
||||
"Consumer Grade": 0, "Industrial Grade": 1,
|
||||
"Mil-Spec": 2, "Mil-Spec Grade": 2,
|
||||
"Restricted": 3, "Classified": 4,
|
||||
"Covert": 5, "Rare Special Item": 6,
|
||||
"Extraordinary": 6, "Contraband": 7
|
||||
}
|
||||
results.sort(key=lambda x: rarity_rank.get(x.get("rarity", ""), 0),
|
||||
reverse=reverse)
|
||||
else:
|
||||
results.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
|
||||
|
||||
return results[:200]
|
||||
|
||||
|
||||
@admin_router.get("/api/items/filters")
|
||||
async def admin_items_filters():
|
||||
"""Возвращает списки уникальных значений для фильтров"""
|
||||
from frontend import ALL_ITEMS
|
||||
|
||||
weapons = set()
|
||||
types = set()
|
||||
for item in ALL_ITEMS:
|
||||
if item.get("is_subvariant"):
|
||||
continue
|
||||
w = item.get("weapon", "")
|
||||
if w:
|
||||
weapons.add(w)
|
||||
t = item.get("type", "")
|
||||
if t:
|
||||
types.add(t)
|
||||
|
||||
return {
|
||||
"weapons": sorted(weapons),
|
||||
"types": sorted(types),
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/api/collections")
|
||||
async def admin_list_collections():
|
||||
"""Возвращает список всех коллекций для фильтра"""
|
||||
from backend import COLLECTIONS_INDEX
|
||||
return {"collections": sorted(COLLECTIONS_INDEX.keys())}
|
||||
|
||||
|
||||
@admin_router.get("/api/items/by-collection")
|
||||
async def admin_items_by_collection(
|
||||
collection: str = "",
|
||||
rarity_filter: str = "",
|
||||
type_filter: str = "",
|
||||
weapon_filter: str = "",
|
||||
include_subvariants: bool = False
|
||||
):
|
||||
"""Возвращает предметы из указанной коллекции"""
|
||||
from backend import COLLECTIONS_INDEX
|
||||
from frontend import get_item
|
||||
results = []
|
||||
seen_bases = set()
|
||||
if collection not in COLLECTIONS_INDEX:
|
||||
return results
|
||||
for idx in COLLECTIONS_INDEX[collection]:
|
||||
item = get_item(idx)
|
||||
if not item:
|
||||
continue
|
||||
if not include_subvariants and item.get("is_subvariant"):
|
||||
continue
|
||||
if rarity_filter:
|
||||
if rarity_filter.lower() not in item.get("rarity", "").lower():
|
||||
continue
|
||||
if type_filter:
|
||||
if type_filter.lower() != item.get("type", "").lower():
|
||||
continue
|
||||
if weapon_filter:
|
||||
if weapon_filter.lower() != item.get("weapon", "").lower():
|
||||
continue
|
||||
name = item.get("market_hash_name", "Unknown")
|
||||
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
|
||||
if dedup_key in seen_bases:
|
||||
continue
|
||||
seen_bases.add(dedup_key)
|
||||
results.append({
|
||||
"id": item_id,
|
||||
"name": item.get("market_hash_name", "Unknown"),
|
||||
"id": item.get("_id", idx),
|
||||
"name": name,
|
||||
"rarity": item.get("rarity", "Unknown"),
|
||||
"image_url": item.get("image_url", ""),
|
||||
"price_rub": item.get("price_rub", 0),
|
||||
"weapon": item.get("weapon", ""),
|
||||
"pattern": item.get("pattern", "")
|
||||
"pattern": item.get("pattern", ""),
|
||||
"collection": item.get("collection", ""),
|
||||
"type": item.get("type", "Normal"),
|
||||
})
|
||||
if len(results) >= 200:
|
||||
break
|
||||
|
||||
return results[:200]
|
||||
return results
|
||||
|
||||
|
||||
@admin_router.post("/api/mainpage/section/add")
|
||||
@@ -787,6 +1015,129 @@ async def admin_get_item(
|
||||
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
|
||||
|
||||
|
||||
# ========== API ПРОМОКОДЫ ==========
|
||||
|
||||
@admin_router.get("/api/promocodes")
|
||||
async def admin_list_promocodes(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
codes = db.query(PromoCode).order_by(desc(PromoCode.created_at)).limit(100).all()
|
||||
return [{
|
||||
"id": c.id,
|
||||
"code": c.code,
|
||||
"reward_type": c.reward_type,
|
||||
"reward_amount": c.reward_amount,
|
||||
"max_uses": c.max_uses,
|
||||
"used_count": c.used_count,
|
||||
"start_date": c.start_date.isoformat() if c.start_date else None,
|
||||
"end_date": c.end_date.isoformat() if c.end_date else None,
|
||||
"is_active": c.is_active,
|
||||
} for c in codes]
|
||||
|
||||
|
||||
@admin_router.post("/api/promocodes/create")
|
||||
async def admin_create_promocode(
|
||||
code: str = Form(...),
|
||||
reward_type: str = Form(...),
|
||||
reward_amount: float = Form(0),
|
||||
reward_data: str = Form(""),
|
||||
max_uses: int = Form(1),
|
||||
end_date_str: str = Form(""),
|
||||
is_active: bool = Form(True),
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
existing = db.query(PromoCode).filter(PromoCode.code == code).first()
|
||||
if existing:
|
||||
return JSONResponse(status_code=400, content={"error": "Промокод с таким кодом уже существует"})
|
||||
|
||||
end_date = None
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date = datetime.fromisoformat(end_date_str)
|
||||
except:
|
||||
pass
|
||||
|
||||
promo = PromoCode(
|
||||
code=code,
|
||||
reward_type=reward_type,
|
||||
reward_amount=reward_amount,
|
||||
reward_data=reward_data,
|
||||
max_uses=max_uses,
|
||||
end_date=end_date,
|
||||
is_active=is_active,
|
||||
created_by=admin.id,
|
||||
)
|
||||
db.add(promo)
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": f"Промокод '{code}' создан"}
|
||||
|
||||
|
||||
@admin_router.post("/api/promocodes/toggle/{promo_id}")
|
||||
async def admin_toggle_promocode(
|
||||
promo_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first()
|
||||
if not promo:
|
||||
return JSONResponse(status_code=404, content={"error": "Промокод не найден"})
|
||||
promo.is_active = not promo.is_active
|
||||
db.commit()
|
||||
return {"success": True, "is_active": promo.is_active}
|
||||
|
||||
|
||||
@admin_router.post("/api/promocodes/delete/{promo_id}")
|
||||
async def admin_delete_promocode(
|
||||
promo_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first()
|
||||
if not promo:
|
||||
return JSONResponse(status_code=404, content={"error": "Промокод не найден"})
|
||||
db.delete(promo)
|
||||
db.commit()
|
||||
return {"success": True, "message": "Промокод удален"}
|
||||
|
||||
|
||||
@admin_router.get("/api/promo-phase")
|
||||
async def admin_get_promo_phase(
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
"""Информация о текущей промо-фазе (для админки)"""
|
||||
from promo_phase import get_phase_debug_info
|
||||
return get_phase_debug_info()
|
||||
|
||||
|
||||
# ========== API ТРАНЗАКЦИИ ==========
|
||||
|
||||
@admin_router.get("/api/transactions")
|
||||
async def admin_list_transactions(
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin)
|
||||
):
|
||||
query = db.query(TransactionLog)
|
||||
if user_id:
|
||||
query = query.filter(TransactionLog.user_id == user_id)
|
||||
txns = query.order_by(desc(TransactionLog.created_at)).limit(limit).all()
|
||||
return [{
|
||||
"id": t.id,
|
||||
"user_id": t.user_id,
|
||||
"tx_type": t.tx_type,
|
||||
"amount": t.amount,
|
||||
"fee": t.fee,
|
||||
"promo_code": t.promo_code,
|
||||
"item_name": t.item_name,
|
||||
"details": t.details,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
} for t in txns]
|
||||
|
||||
|
||||
@admin_router.post("/api/reset-password/{user_id}")
|
||||
async def admin_reset_password(
|
||||
user_id: int,
|
||||
|
||||
+107
@@ -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()
|
||||
+1891
-1
File diff suppressed because it is too large
Load Diff
+201
-3
@@ -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:
|
||||
|
||||
+479
-273
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"phase": "LUCKY",
|
||||
"until": "2026-07-22T19:34:16.536290",
|
||||
"started_at": "2026-07-22T18:22:18.082890"
|
||||
}
|
||||
+178
@@ -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, {})
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
# Вычисляем общий уровень РПУ (0-100%)
|
||||
rpu_level = calculate_rpu_level(rpu)
|
||||
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
|
||||
|
||||
# Переворачиваем: низкий РПУ = высокий множитель для редких
|
||||
# При 0% РПУ множитель для редких = 2.0 (очень везёт)
|
||||
# При 50% РПУ множитель = 1.0 (обычно)
|
||||
# При 100% РПУ множитель = 0.1 (совсем не везёт)
|
||||
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
|
||||
|
||||
В 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 = RARITY_RANK.get(rarity, 0)
|
||||
|
||||
# Определяем "редкость" предмета (чем реже, тем больше индекс)
|
||||
rarity_index = 0
|
||||
if "Industrial" in rarity:
|
||||
rarity_index = 1
|
||||
elif "Mil-Spec" in rarity:
|
||||
rarity_index = 2
|
||||
elif "Restricted" in rarity:
|
||||
rarity_index = 3
|
||||
elif "Classified" in rarity:
|
||||
rarity_index = 4
|
||||
elif "Covert" in rarity:
|
||||
rarity_index = 5
|
||||
elif "Rare Special" in rarity or "Extraordinary" in rarity:
|
||||
rarity_index = 6
|
||||
|
||||
# Чем выше РПУ, тем сильнее режем шансы на редкое
|
||||
# Чем ниже РПУ, тем сильнее повышаем шансы на редкое
|
||||
if rpu_level < 50:
|
||||
# Везёт: повышаем шансы на редкое
|
||||
luck_factor = 1.0 + ((50 - rpu_level) / 50) * rarity_index * 0.3
|
||||
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,
|
||||
@@ -111,11 +499,8 @@ def calculate_rpu_level(rpu: UserRPU) -> float:
|
||||
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:
|
||||
@@ -125,7 +510,6 @@ def calculate_rpu_level(rpu: UserRPU) -> float:
|
||||
|
||||
|
||||
def get_rpu_description(level: float) -> str:
|
||||
"""Возвращает текстовое описание уровня РПУ"""
|
||||
if level < 20:
|
||||
return "🍀 Очень везучий"
|
||||
elif level < 35:
|
||||
@@ -143,9 +527,7 @@ 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
|
||||
|
||||
@@ -153,22 +535,43 @@ def auto_adjust_rpu(db: Session, user_id: int):
|
||||
if not user:
|
||||
return
|
||||
|
||||
# Анализируем статистику
|
||||
if rpu.total_opened > 0:
|
||||
avg_spent_per_case = rpu.total_spent / rpu.total_opened
|
||||
if rpu.total_opened > 0 and rpu.total_spent > 0:
|
||||
roi = rpu.total_value_received / rpu.total_spent
|
||||
|
||||
# Если пользователь много тратит и мало получает - снижаем РПУ (даём везение)
|
||||
if 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 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']:
|
||||
@@ -177,16 +580,105 @@ def auto_adjust_rpu(db: Session, user_id: int):
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
+5
-4
@@ -14,9 +14,10 @@ async function refreshBalance() {
|
||||
const res = await fetch('/web/api/user/balance');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.balance)} ₽`;
|
||||
});
|
||||
const el = document.getElementById('siteBalanceDisplay');
|
||||
if (el) {
|
||||
el.innerHTML = Math.floor(data.balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -140,7 +141,7 @@ function showAchievementPopup(title, reward) {
|
||||
}
|
||||
|
||||
document.getElementById('globalPopupTitle').textContent = title;
|
||||
document.getElementById('globalPopupReward').textContent = reward ? `+${reward} ₽` : '';
|
||||
document.getElementById('globalPopupReward').textContent = '';
|
||||
popup.style.display = 'block';
|
||||
|
||||
if (window.SoundManager) SoundManager.achievement();
|
||||
|
||||
+88
-49
@@ -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());
|
||||
|
||||
@@ -140,6 +140,56 @@
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Иммерсивные эффекты для ленты */
|
||||
.asi-effect {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.asi-effect::before {
|
||||
content: attr(data-effect-text);
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
color: currentColor;
|
||||
opacity: 0.06;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.asi-effect-glow {
|
||||
box-shadow: 0 0 15px 2px rgba(255, 200, 50, 0.15), inset 0 0 20px rgba(255, 200, 50, 0.05);
|
||||
animation: glowPulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes glowPulse {
|
||||
0%,100% { box-shadow: 0 0 15px 2px rgba(255,200,50,0.15), inset 0 0 20px rgba(255,200,50,0.05); }
|
||||
50% { box-shadow: 0 0 25px 5px rgba(255,200,50,0.25), inset 0 0 30px rgba(255,200,50,0.08); }
|
||||
}
|
||||
.asi-effect-shimmer {
|
||||
position: absolute;
|
||||
top: 0; left: -100%;
|
||||
width: 100%; height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.07), transparent);
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { left: -100%; }
|
||||
50% { left: 200%; }
|
||||
100% { left: -100%; }
|
||||
}
|
||||
.asi-effect-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.asi-effect-badge.upgrade { background: rgba(139,92,246,0.25); color: #a78bfa; }
|
||||
.asi-effect-badge.drop { background: rgba(251,191,36,0.25); color: #fbbf24; }
|
||||
.has-activity-sidebar {
|
||||
padding-left: 260px;
|
||||
transition: padding-left 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
@@ -157,9 +207,6 @@
|
||||
<div class="activity-sidebar" id="activitySidebar">
|
||||
<div class="activity-sidebar-header">
|
||||
<h3><span class="live-dot"></span> Лента</h3>
|
||||
<span style="font-size:0.7rem;color:var(--text-secondary);display:flex;align-items:center;gap:0.25rem;">
|
||||
<span id="onlineCount">0</span> 👤
|
||||
</span>
|
||||
<button class="activity-sidebar-close" onclick="toggleActivitySidebar()" title="Скрыть">✕</button>
|
||||
</div>
|
||||
<div class="activity-sidebar-list" id="activitySidebarList">
|
||||
@@ -198,11 +245,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
WS.on('online_count', (data) => {
|
||||
const el = document.getElementById('onlineCount');
|
||||
if (el) el.textContent = data.online || 0;
|
||||
});
|
||||
|
||||
WS.on('activity', (data) => {
|
||||
const act = data.activity;
|
||||
if (!act || !list) return;
|
||||
@@ -241,9 +283,33 @@ function createActivityItem(act) {
|
||||
|
||||
const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, '');
|
||||
const rarityColor = ACTIVITY_RARITY_COLORS[raritySlug] || '';
|
||||
|
||||
// ── Эффект ──
|
||||
const effect = act.data && act.data.effect;
|
||||
let effectBadgeHtml = '';
|
||||
if (effect) {
|
||||
const isUpgrade = effect.startsWith('upgrade:');
|
||||
const isDrop = effect.startsWith('drop:');
|
||||
const label = effect.replace('upgrade:', '').replace('drop:', '');
|
||||
const cls = isUpgrade ? 'upgrade' : 'drop';
|
||||
effectBadgeHtml = `<span class="asi-effect-badge ${cls}">${label}</span>`;
|
||||
item.classList.add('asi-effect');
|
||||
item.dataset.effectText = label;
|
||||
if (isDrop) {
|
||||
item.classList.add('asi-effect-glow');
|
||||
const sh = document.createElement('div');
|
||||
sh.className = 'asi-effect-shimmer';
|
||||
item.appendChild(sh);
|
||||
}
|
||||
if (rarityColor) {
|
||||
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.12)`;
|
||||
item.style.borderLeft = `3px solid ${rarityColor}`;
|
||||
}
|
||||
} else {
|
||||
if (rarityColor) {
|
||||
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
|
||||
}
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => window.location.href = `/profiles/${act.user_id}`);
|
||||
|
||||
@@ -260,6 +326,7 @@ function createActivityItem(act) {
|
||||
<span class="asi-username">${escapeHtml(act.username)}</span>
|
||||
<span>${escapeHtml(act.message)}</span>
|
||||
<div class="asi-time">${timeStr}</div>
|
||||
${effectBadgeHtml}
|
||||
</div>
|
||||
${imgHtml}
|
||||
`;
|
||||
|
||||
@@ -257,9 +257,6 @@
|
||||
{% if ach.hidden %}<div class="secret-badge">🤫 Секрет</div>{% endif %}
|
||||
<div class="achievement-title">{{ ach.title }}</div>
|
||||
<div class="achievement-desc">{{ ach.description }}</div>
|
||||
{% if ach.reward_amount > 0 %}
|
||||
<div class="achievement-reward">+{{ ach.reward_amount|money }} ₽</div>
|
||||
{% endif %}
|
||||
<div class="achievement-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill {{ 'complete' if ach.unlocked else '' }}"
|
||||
@@ -308,7 +305,7 @@
|
||||
function showAchievementPopup(title, reward) {
|
||||
const popup = document.getElementById('achievementPopup');
|
||||
document.getElementById('popupTitle').textContent = title;
|
||||
document.getElementById('popupReward').textContent = reward ? `+${reward} ₽` : '';
|
||||
document.getElementById('popupReward').textContent = '';
|
||||
popup.style.display = 'block';
|
||||
SoundManager.achievement();
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Лента активностей - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
.activity-feed {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.activity-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: flex-start;
|
||||
transition: all 0.3s;
|
||||
animation: fadeInUp 0.3s ease;
|
||||
}
|
||||
.activity-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.activity-icon {
|
||||
font-size: 1.2rem;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.activity-message {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.activity-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.activity-username {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
.activity-empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.activity-empty .big-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.activity-type-icon {
|
||||
display: inline-block;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.activity-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.live-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--success-color);
|
||||
}
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--success-color);
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.5s ease infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="container" style="padding: 2rem 1rem;">
|
||||
<div class="activity-header">
|
||||
<div>
|
||||
<h1>📰 Лента активностей</h1>
|
||||
<p class="page-subtitle">Что происходит на сервере</p>
|
||||
</div>
|
||||
<div class="live-indicator" id="liveIndicator">
|
||||
<span class="live-dot"></span>
|
||||
<span>Live</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activity-feed" id="activityFeed">
|
||||
{% if activities %}
|
||||
{% for a in activities %}
|
||||
<div class="activity-item" data-activity-id="{{ a.id }}">
|
||||
<div class="activity-icon">
|
||||
{% if a.activity_type == 'case_open' %}📦
|
||||
{% elif a.activity_type == 'contract' %}🔄
|
||||
{% elif a.activity_type == 'upgrade' %}🆙
|
||||
{% elif a.activity_type == 'achievement' %}🏆
|
||||
{% elif a.activity_type == 'crash' %}💥
|
||||
{% else %}📌{% endif %}
|
||||
</div>
|
||||
<div class="activity-content">
|
||||
<div class="activity-message">{{ a.message }}</div>
|
||||
<div class="activity-meta">
|
||||
<a href="/profiles/{{ a.user_id }}" class="activity-username" style="text-decoration:none;">{{ a.username }}</a>
|
||||
<span>• {{ a.created_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="activity-empty">
|
||||
<div class="big-icon">📭</div>
|
||||
<h3>Пока нет активностей</h3>
|
||||
<p>Открой кейс или выполни контракт — это появится здесь в реальном времени!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
WS.on('activity', (data) => {
|
||||
const act = data.activity;
|
||||
if (!act) return;
|
||||
|
||||
const feed = document.getElementById('activityFeed');
|
||||
|
||||
const empty = feed.querySelector('.activity-empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
const iconMap = {
|
||||
'case_open': '📦',
|
||||
'contract': '🔄',
|
||||
'upgrade': '🆙',
|
||||
'achievement': '🏆',
|
||||
'crash': '💥'
|
||||
};
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'activity-item';
|
||||
item.style.animation = 'none';
|
||||
item.offsetHeight;
|
||||
item.style.animation = 'fadeInUp 0.3s ease';
|
||||
item.innerHTML = `
|
||||
<div class="activity-icon">${iconMap[act.activity_type] || '📌'}</div>
|
||||
<div class="activity-content">
|
||||
<div class="activity-message">${act.message}</div>
|
||||
<div class="activity-meta">
|
||||
<a href="/profiles/${act.user_id || 0}" class="activity-username" style="text-decoration:none;">${escapeHtml(act.username)}</a>
|
||||
<span>• только что</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
feed.insertBefore(item, feed.firstChild);
|
||||
|
||||
while (feed.children.length > 100) {
|
||||
feed.removeChild(feed.lastChild);
|
||||
}
|
||||
|
||||
if (act.activity_type === 'achievement') {
|
||||
SoundManager.achievement();
|
||||
} else if (act.activity_type === 'crash') {
|
||||
SoundManager.crashCashout();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
function toggleSound() {
|
||||
const enabled = SoundManager.toggle();
|
||||
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('soundToggle');
|
||||
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+151
-9
@@ -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; }
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media(max-width: 1024px){
|
||||
.admin-sidebar { width: 200px; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
.admin-sidebar {
|
||||
position: fixed; left: 0; top: 0;
|
||||
transform: translateX(-100%);
|
||||
width: 260px;
|
||||
box-shadow: 4px 0 30px rgba(0,0,0,0.4);
|
||||
}
|
||||
.admin-sidebar.open { transform: translateX(0); }
|
||||
.admin-content { padding: 1rem; }
|
||||
.admin-topbar { padding: 0.7rem 1rem; }
|
||||
.admin-topbar .at-title .at-sub { display: none; }
|
||||
|
||||
.hamburger { display: inline-flex; }
|
||||
|
||||
.a-stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.a-stat { padding: 1rem; }
|
||||
.a-stat .as-value { font-size: 1.4rem; }
|
||||
|
||||
.search-form .a-input { width: 140px; }
|
||||
|
||||
.a-modal { margin: 0.5rem; padding: 1rem; border-radius: 12px; }
|
||||
|
||||
/* Card-style table on mobile */
|
||||
.a-table { display: none; }
|
||||
.a-table-card-view { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.a-table-card {
|
||||
background: rgba(17,18,22,0.6);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.a-table-card .tc-row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.a-table-card .tc-row .tc-label {
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-size: 0.72rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.a-table-card .tc-row .tc-value {
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
.a-table-card .tc-actions {
|
||||
margin-top: 0.5rem; padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
display: flex; gap: 0.35rem; flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 480px){
|
||||
html { font-size: 14px; }
|
||||
.admin-content { padding: 0.75rem; }
|
||||
.admin-topbar { padding: 0.6rem 0.75rem; }
|
||||
.a-stats { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||
.a-stat { padding: 0.75rem; }
|
||||
.a-stat .as-value { font-size: 1.2rem; }
|
||||
.a-card { padding: 0.85rem; }
|
||||
.search-form { width: 100%; }
|
||||
.search-form .a-input { width: 100%; }
|
||||
}
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="admin-sidebar">
|
||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||
|
||||
<div class="admin-sidebar" id="adminSidebar">
|
||||
<div class="sb-brand">
|
||||
<span style="font-size:1.2rem">⚡</span>
|
||||
<span>CS2 <span>Admin</span></span>
|
||||
@@ -344,6 +457,7 @@
|
||||
<div class="admin-main">
|
||||
<div class="admin-topbar">
|
||||
<div class="at-title">
|
||||
<button class="hamburger" id="hamburgerBtn" aria-label="Toggle menu">☰</button>
|
||||
{% block page_title %}Панель управления{% endblock %}
|
||||
</div>
|
||||
<div class="at-actions">
|
||||
@@ -357,6 +471,34 @@
|
||||
|
||||
{% block modals %}{% endblock %}
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const sidebar = document.getElementById('adminSidebar');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
const hamburger = document.getElementById('hamburgerBtn');
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('open');
|
||||
}
|
||||
|
||||
if (hamburger) {
|
||||
hamburger.addEventListener('click', toggleSidebar);
|
||||
}
|
||||
if (overlay) {
|
||||
overlay.addEventListener('click', toggleSidebar);
|
||||
}
|
||||
|
||||
// Auto-close sidebar on nav click (mobile)
|
||||
sidebar.querySelectorAll('.sb-nav a').forEach(function(link) {
|
||||
link.addEventListener('click', function() {
|
||||
if (window.innerWidth <= 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+241
-74
@@ -53,6 +53,32 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="a-table-card-view">
|
||||
{% for c in cases %}
|
||||
<div class="a-table-card">
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Название</span>
|
||||
<span class="tc-value"><strong>{{ c.name }}</strong></span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Цена</span>
|
||||
<span class="tc-value">{{ c.price_open }} ₽</span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Тип</span>
|
||||
<span class="tc-value">{{ c.display_name|default('') }}</span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Предметов</span>
|
||||
<span class="tc-value">{{ c.items_count }}</span>
|
||||
</div>
|
||||
<div class="tc-actions">
|
||||
<button class="a-btn a-btn-sm" onclick="openEditCaseModal('{{ c.name }}')">✏️ Редактировать</button>
|
||||
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteCase('{{ c.name }}')">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -60,9 +86,10 @@
|
||||
{% block modals %}
|
||||
<!-- Create/Edit Case Modal -->
|
||||
<div class="a-overlay" id="caseModal">
|
||||
<div class="a-modal" style="max-width:600px">
|
||||
<div class="a-modal a-modal-wide" style="max-height:95vh;display:flex;flex-direction:column">
|
||||
<div class="a-modal-title" id="caseModalTitle">🎲 Создать кейс</div>
|
||||
<input type="hidden" id="editCaseName">
|
||||
<div style="padding:0 1.5rem;flex:1;overflow-y:auto">
|
||||
<div class="a-row a-gap-sm">
|
||||
<div class="a-mb" style="flex:1">
|
||||
<label class="a-label">Название кейса (ID)</label>
|
||||
@@ -87,30 +114,62 @@
|
||||
<label class="a-label">Описание (необязательно)</label>
|
||||
<input type="text" id="caseDescription" class="a-input" placeholder="Описание кейса...">
|
||||
</div>
|
||||
<div class="a-card" style="background:rgba(0,0,0,0.2);padding:0.75rem">
|
||||
<div class="a-card-header" style="margin-bottom:0.5rem">📋 Предметы кейса</div>
|
||||
<div id="caseItemsList"></div>
|
||||
<div class="a-flex a-mt" style="flex-wrap:wrap">
|
||||
<input type="text" id="caseItemSearch" class="a-input a-input-sm" style="flex:1;min-width:120px" placeholder="Поиск предмета..." oninput="searchCaseItems()">
|
||||
<button class="a-btn a-btn-sm" onclick="openAllItemsModal()">📦 Все предметы</button>
|
||||
|
||||
<!-- Item browser section -->
|
||||
<div class="a-card" style="background:rgba(0,0,0,0.2);padding:0.75rem;margin-bottom:1rem">
|
||||
<div class="a-card-header" style="margin-bottom:0.5rem">📋 Предметы кейса <span id="itemCount" style="font-size:0.75rem;color:rgba(255,255,255,0.4)">(0)</span></div>
|
||||
|
||||
<!-- Selected items list -->
|
||||
<div id="caseItemsList" style="max-height:200px;overflow-y:auto;margin-bottom:0.75rem"></div>
|
||||
|
||||
<!-- Search + filters + add controls -->
|
||||
<div class="a-row a-gap-sm" style="margin-bottom:0.5rem">
|
||||
<input type="text" id="caseItemSearch" class="a-input a-input-sm" style="flex:2;min-width:120px" placeholder="🔍 Поиск..." oninput="searchCaseItems()">
|
||||
<select id="rarityFilter" class="a-input a-input-sm" style="flex:1;min-width:90px" onchange="searchCaseItems()">
|
||||
<option value="">Редкость</option>
|
||||
<option value="Consumer Grade">Consumer</option>
|
||||
<option value="Industrial Grade">Industrial</option>
|
||||
<option value="Mil-Spec">Mil-Spec</option>
|
||||
<option value="Restricted">Restricted</option>
|
||||
<option value="Classified">Classified</option>
|
||||
<option value="Covert">Covert</option>
|
||||
<option value="Rare Special Item">Rare Special</option>
|
||||
<option value="Contraband">Contraband</option>
|
||||
</select>
|
||||
<select id="collectionFilter" class="a-input a-input-sm" style="flex:1.2;min-width:100px" onchange="searchCaseItems()">
|
||||
<option value="">Коллекция</option>
|
||||
</select>
|
||||
<select id="sortBy" class="a-input a-input-sm" style="flex:0.7;min-width:80px" onchange="searchCaseItems()">
|
||||
<option value="">Сортировка</option>
|
||||
<option value="price_rub">Цена ↑</option>
|
||||
<option value="price_rub&desc">Цена ↓</option>
|
||||
<option value="name">Имя ↑</option>
|
||||
<option value="rarity">Редкость ↑</option>
|
||||
<option value="rarity&desc">Редкость ↓</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="a-row a-gap-sm" style="margin-bottom:0.5rem">
|
||||
<select id="typeFilter" class="a-input a-input-sm" style="flex:1;min-width:70px" onchange="searchCaseItems()">
|
||||
<option value="">Тип</option>
|
||||
</select>
|
||||
<select id="weaponFilter" class="a-input a-input-sm" style="flex:1;min-width:80px" onchange="searchCaseItems()">
|
||||
<option value="">Оружие</option>
|
||||
</select>
|
||||
<input type="number" id="minPrice" class="a-input a-input-sm" style="flex:0.6;min-width:60px" placeholder="Цена от" oninput="searchCaseItems()" value="0">
|
||||
<input type="number" id="maxPrice" class="a-input a-input-sm" style="flex:0.6;min-width:60px" placeholder="Цена до" oninput="searchCaseItems()" value="0">
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div id="caseItemResults" style="max-height:220px;overflow-y:auto;border:1px solid rgba(255,255,255,0.06);border-radius:6px;padding:0.25rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-modal-actions">
|
||||
<div class="a-modal-actions" style="padding:0.75rem 1.5rem;border-top:1px solid rgba(255,255,255,0.06)">
|
||||
<button class="a-btn" onclick="closeModal('caseModal')">Отмена</button>
|
||||
<button class="a-btn a-btn-primary" id="saveCaseBtn" onclick="saveCase()">💾 Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All items browser modal -->
|
||||
<div class="a-overlay" id="allItemsModal">
|
||||
<div class="a-modal" style="max-width:700px;max-height:80vh;display:flex;flex-direction:column">
|
||||
<div class="a-modal-title">📦 Все предметы</div>
|
||||
<input type="text" id="allItemsSearch" class="a-input a-input-sm a-mb" placeholder="Поиск..." oninput="searchAllItems()">
|
||||
<div id="allItemsGrid" style="flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.5rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add case to section modal -->
|
||||
<div class="a-overlay" id="addCaseModal">
|
||||
<div class="a-modal" style="max-width:400px">
|
||||
@@ -145,13 +204,26 @@
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.ci-item { display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0; border-bottom:1px solid rgba(255,255,255,0.03); font-size:0.78rem; }
|
||||
.ci-item img { width:32px; height:24px; object-fit:contain; }
|
||||
.ci-item .ci-info { flex:1; }
|
||||
.ci-item {
|
||||
display:flex; align-items:center; gap:0.5rem;
|
||||
padding:0.3rem 0.5rem; border-bottom:1px solid rgba(255,255,255,0.04);
|
||||
font-size:0.78rem; transition:background 0.15s;
|
||||
}
|
||||
.ci-item:hover { background:rgba(255,255,255,0.03); }
|
||||
.ci-item img { width:36px; height:28px; object-fit:contain; border-radius:3px; }
|
||||
.ci-item .ci-info { flex:1; line-height:1.3; }
|
||||
.ci-item .ci-rarity { font-size:0.65rem; color:rgba(255,255,255,0.35); }
|
||||
.all-item-card { background:rgba(0,0,0,0.15); border-radius:6px; padding:0.35rem; cursor:pointer; text-align:center; font-size:0.7rem; transition:all 0.15s; border:1px solid transparent; }
|
||||
.all-item-card:hover { border-color:rgba(245,158,11,0.3); background:rgba(245,158,11,0.05); }
|
||||
.all-item-card img { width:100%; height:40px; object-fit:contain; }
|
||||
.ci-result {
|
||||
display:flex; align-items:center; gap:0.5rem;
|
||||
padding:0.3rem 0.5rem; cursor:pointer;
|
||||
border-bottom:1px solid rgba(255,255,255,0.03);
|
||||
font-size:0.78rem; transition:all 0.12s; border-radius:3px;
|
||||
}
|
||||
.ci-result:hover { background:rgba(245,158,11,0.08); }
|
||||
.ci-result img { width:36px; height:28px; object-fit:contain; border-radius:3px; }
|
||||
.ci-result .ci-info { flex:1; line-height:1.3; }
|
||||
.ci-result .ci-meta { font-size:0.65rem; color:rgba(255,255,255,0.35); }
|
||||
.ci-result .ci-add { font-size:0.7rem; color:#f59e0b; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -159,17 +231,42 @@
|
||||
<script>
|
||||
let currentCaseItems = [];
|
||||
let editingCaseName = null;
|
||||
let searchDebounce = null;
|
||||
|
||||
// ─── Modals ──────────────────────────────────────────────────────────────────
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||
|
||||
// ─── Load page data ──────────────────────────────────────────────────────────
|
||||
let allCasesList = [];
|
||||
// ─── Load filter options ─────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const r = await fetch('/admin/api/cases/list');
|
||||
const d = await r.json();
|
||||
allCasesList = d.cases || [];
|
||||
allCasesList = await r.json();
|
||||
|
||||
// Load collections
|
||||
const cr = await fetch('/admin/api/collections');
|
||||
const cd = await cr.json();
|
||||
const colSel = document.getElementById('collectionFilter');
|
||||
(cd.collections || []).forEach(c => {
|
||||
const o = document.createElement('option');
|
||||
o.value = c; o.textContent = c;
|
||||
colSel.appendChild(o);
|
||||
});
|
||||
|
||||
// Load weapons + types
|
||||
const fr = await fetch('/admin/api/items/filters');
|
||||
const fd = await fr.json();
|
||||
const typeSel = document.getElementById('typeFilter');
|
||||
(fd.types || []).forEach(t => {
|
||||
const o = document.createElement('option');
|
||||
o.value = t; o.textContent = t;
|
||||
typeSel.appendChild(o);
|
||||
});
|
||||
const weaponSel = document.getElementById('weaponFilter');
|
||||
(fd.weapons || []).forEach(w => {
|
||||
const o = document.createElement('option');
|
||||
o.value = w; o.textContent = w;
|
||||
weaponSel.appendChild(o);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Create / Edit Case ──────────────────────────────────────────────────────
|
||||
@@ -185,6 +282,8 @@ function openCreateCaseModal() {
|
||||
document.getElementById('caseDescription').value = '';
|
||||
currentCaseItems = [];
|
||||
renderCaseItems();
|
||||
document.getElementById('caseItemSearch').value = '';
|
||||
document.getElementById('caseItemResults').innerHTML = '';
|
||||
openModal('caseModal');
|
||||
}
|
||||
|
||||
@@ -200,21 +299,34 @@ function openEditCaseModal(caseName) {
|
||||
document.getElementById('casePrice').value = c.price_open || 250;
|
||||
document.getElementById('caseImage').value = c.image_url || '';
|
||||
document.getElementById('caseDescription').value = c.description || '';
|
||||
currentCaseItems = (c.items || []).filter(i => typeof i === 'object');
|
||||
currentCaseItems = (c.items || []).filter(i => typeof i === 'object').map(i => {
|
||||
if (typeof i.id === 'number' && !i.market_hash_name) {
|
||||
return { id: i.id, name: i.name || 'Item #' + i.id, market_hash_name: i.name || '' };
|
||||
}
|
||||
return i;
|
||||
});
|
||||
renderCaseItems();
|
||||
document.getElementById('caseItemSearch').value = '';
|
||||
document.getElementById('caseItemResults').innerHTML = '';
|
||||
openModal('caseModal');
|
||||
}
|
||||
|
||||
function renderCaseItems() {
|
||||
const div = document.getElementById('caseItemsList');
|
||||
const count = currentCaseItems.length;
|
||||
document.getElementById('itemCount').textContent = `(${count})`;
|
||||
if (count === 0) {
|
||||
div.innerHTML = '<div style="font-size:0.75rem;color:rgba(255,255,255,0.3);padding:0.5rem 0">Нет предметов — добавьте через поиск выше</div>';
|
||||
return;
|
||||
}
|
||||
div.innerHTML = currentCaseItems.map((item, i) => `
|
||||
<div class="ci-item">
|
||||
<div class="ci-item" onclick="removeCaseItem(${i})" style="cursor:pointer">
|
||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="ci-info">
|
||||
<div>${item.market_hash_name || item.name || '???'}</div>
|
||||
<div class="ci-rarity">${item.rarity || ''} • ${item.weapon || ''}</div>
|
||||
<div class="ci-rarity">${item.rarity || ''}${item.collection ? ' • ' + item.collection : ''}</div>
|
||||
</div>
|
||||
<button class="a-btn a-btn-danger a-btn-sm" onclick="removeCaseItem(${i})">✕</button>
|
||||
<span style="font-size:0.65rem;color:#ef4444;opacity:0.6">✕</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
@@ -225,31 +337,105 @@ function removeCaseItem(idx) {
|
||||
}
|
||||
|
||||
function addCaseItem(item) {
|
||||
if (currentCaseItems.some(i => (i.market_hash_name || i.name) === (item.market_hash_name || item.name))) return;
|
||||
const key = (item.market_hash_name || item.name || '');
|
||||
if (currentCaseItems.some(i => (i.market_hash_name || i.name) === key)) return;
|
||||
currentCaseItems.push(item);
|
||||
renderCaseItems();
|
||||
}
|
||||
|
||||
function addAllFromCollection() {
|
||||
const sel = document.getElementById('collectionFilter');
|
||||
const coll = sel.value;
|
||||
if (!coll) { Notify.error('Выберите коллекцию'); return; }
|
||||
const rarity = document.getElementById('rarityFilter').value;
|
||||
const typeF = document.getElementById('typeFilter').value;
|
||||
const weaponF = document.getElementById('weaponFilter').value;
|
||||
let url = `/admin/api/items/by-collection?collection=${encodeURIComponent(coll)}`;
|
||||
if (rarity) url += `&rarity_filter=${encodeURIComponent(rarity)}`;
|
||||
if (typeF) url += `&type_filter=${encodeURIComponent(typeF)}`;
|
||||
if (weaponF) url += `&weapon_filter=${encodeURIComponent(weaponF)}`;
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(items => {
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
const alreadyIds = new Set(currentCaseItems.map(i => i.id));
|
||||
items.forEach(item => {
|
||||
if (alreadyIds.has(item.id)) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
const key = (item.name || '');
|
||||
if (!currentCaseItems.some(i => (i.market_hash_name || i.name) === key)) {
|
||||
currentCaseItems.push(item);
|
||||
added++;
|
||||
}
|
||||
});
|
||||
renderCaseItems();
|
||||
let msg = `Добавлено ${added} предметов из "${coll}"`;
|
||||
if (skipped) msg += ` (${skipped} уже были в кейсе)`;
|
||||
Notify.success(msg);
|
||||
});
|
||||
}
|
||||
|
||||
async function searchCaseItems() {
|
||||
const q = document.getElementById('caseItemSearch').value;
|
||||
if (q.length < 2) return;
|
||||
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}`);
|
||||
const items = await r.json();
|
||||
// show as dropdown
|
||||
let html = '<div style="margin-top:0.5rem;max-height:200px;overflow-y:auto">';
|
||||
items.forEach(item => {
|
||||
html += `<div class="ci-item" style="cursor:pointer" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'"')})">
|
||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="ci-info"><div>${item.name}</div><div class="ci-rarity">${item.rarity} • ${item.price_rub||0} ₽</div></div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
const existing = document.getElementById('caseItemSearchResults');
|
||||
if (!existing) {
|
||||
const d = document.createElement('div'); d.id = 'caseItemSearchResults';
|
||||
document.getElementById('caseItemSearch').parentNode.appendChild(d);
|
||||
const rarity = document.getElementById('rarityFilter').value;
|
||||
const collection = document.getElementById('collectionFilter').value;
|
||||
const typeF = document.getElementById('typeFilter').value;
|
||||
const weaponF = document.getElementById('weaponFilter').value;
|
||||
const minP = document.getElementById('minPrice').value;
|
||||
const maxP = document.getElementById('maxPrice').value;
|
||||
const sortV = document.getElementById('sortBy').value;
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (q.length >= 2) params.set('q', q);
|
||||
if (rarity) params.set('rarity_filter', rarity);
|
||||
if (collection) params.set('collection_filter', collection);
|
||||
if (typeF) params.set('type_filter', typeF);
|
||||
if (weaponF) params.set('weapon_filter', weaponF);
|
||||
if (minP > 0) params.set('min_price', minP);
|
||||
if (maxP > 0) params.set('max_price', maxP);
|
||||
if (sortV) {
|
||||
const [field, order] = sortV.split('&');
|
||||
params.set('sort_by', field);
|
||||
if (order) params.set('sort_order', 'desc');
|
||||
}
|
||||
document.getElementById('caseItemSearchResults').innerHTML = html;
|
||||
params.set('limit', '100');
|
||||
const r = await fetch(`/admin/api/items/search?${params.toString()}`);
|
||||
const items = await r.json();
|
||||
const div = document.getElementById('caseItemResults');
|
||||
if (items.length === 0) {
|
||||
div.innerHTML = '<div style="font-size:0.75rem;color:rgba(255,255,255,0.3);padding:0.5rem">Ничего не найдено</div>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
if (collection) {
|
||||
html += `<div style="padding:0.3rem 0.5rem;font-size:0.7rem;color:rgba(255,255,255,0.4);display:flex;justify-content:space-between">
|
||||
<span>Найдено: ${items.length} предметов</span>
|
||||
<span onclick="addAllFromCollection()" style="cursor:pointer;color:#f59e0b">➕ Добавить все из коллекции</span>
|
||||
</div>`;
|
||||
}
|
||||
const alreadyIds = new Set(currentCaseItems.map(i => i.id));
|
||||
html += items.filter(item => !alreadyIds.has(item.id)).map(item => {
|
||||
const priceStr = item.price_rub > 0 ? item.price_rub.toLocaleString('ru') + '₽' : '';
|
||||
return `
|
||||
<div class="ci-result" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'"')})">
|
||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="ci-info">
|
||||
<div>${item.name}</div>
|
||||
<div class="ci-meta">${item.rarity}${item.weapon ? ' • ' + item.weapon : ''}${priceStr ? ' • ' + priceStr : ''}</div>
|
||||
</div>
|
||||
<div class="ci-add">+</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const hidden = items.filter(item => alreadyIds.has(item.id)).length;
|
||||
if (hidden > 0) {
|
||||
html += `<div style="font-size:0.65rem;color:rgba(255,255,255,0.25);padding:0.3rem 0.5rem">${hidden} предмет(ов) уже в кейсе</div>`;
|
||||
}
|
||||
div.innerHTML = html;
|
||||
}, q.length >= 2 ? 200 : 300);
|
||||
}
|
||||
|
||||
async function saveCase() {
|
||||
@@ -263,11 +449,15 @@ async function saveCase() {
|
||||
fd.append('description', document.getElementById('caseDescription').value);
|
||||
fd.append('items_json', JSON.stringify(currentCaseItems));
|
||||
|
||||
const isEdit = editingCaseName !== null && editingCaseName !== name;
|
||||
const isEdit = editingCaseName !== null;
|
||||
const url = isEdit
|
||||
? `/admin/api/cases/update/${encodeURIComponent(editingCaseName)}`
|
||||
: '/admin/api/cases/create';
|
||||
|
||||
if (isEdit && editingCaseName !== name) {
|
||||
fd.append('new_name', name);
|
||||
}
|
||||
|
||||
const r = await fetch(url, { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) { window.location.reload(); }
|
||||
@@ -285,29 +475,6 @@ async function deleteCase(name) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── All Items Browser ───────────────────────────────────────────────────────
|
||||
async function openAllItemsModal() {
|
||||
openModal('allItemsModal');
|
||||
document.getElementById('allItemsSearch').value = '';
|
||||
await searchAllItems();
|
||||
}
|
||||
async function searchAllItems() {
|
||||
const q = document.getElementById('allItemsSearch').value;
|
||||
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}&limit=200`);
|
||||
const items = await r.json();
|
||||
document.getElementById('allItemsGrid').innerHTML = items.map(item => `
|
||||
<div class="all-item-card" onclick="addCaseItemFromAll(${JSON.stringify(item).replace(/"/g,'"')})">
|
||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||
<div style="margin-top:0.2rem;line-height:1.15">${item.name}</div>
|
||||
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35)">${item.rarity}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
function addCaseItemFromAll(item) {
|
||||
addCaseItem(item);
|
||||
closeModal('allItemsModal');
|
||||
}
|
||||
|
||||
// ─── Mainpage Sections ───────────────────────────────────────────────────────
|
||||
function openAddSectionModal() {
|
||||
document.getElementById('newSectionName').value = '';
|
||||
@@ -336,7 +503,7 @@ async function deleteSection(tab) {
|
||||
function openAddCaseModal(tab) {
|
||||
document.getElementById('addCaseSectionName').textContent = tab;
|
||||
const sel = document.getElementById('addCaseSelect');
|
||||
sel.innerHTML = allCasesList.map(c => `<option value="${c}">${c}</option>`).join('');
|
||||
sel.innerHTML = allCasesList.map(c => `<option value="${c.name}">${c.display_name || c.name}</option>`).join('');
|
||||
sel.dataset.tab = tab;
|
||||
openModal('addCaseModal');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
{% block page_title %}📊 Дашборд{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-card" style="margin-bottom:1rem;padding:0.75rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>🎭 Промо-фаза:</strong>
|
||||
<span id="promoPhaseInfo">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a-stats">
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Пользователей</div>
|
||||
@@ -55,3 +62,23 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function loadPromoPhase() {
|
||||
try {
|
||||
const r = await fetch('/admin/api/promo-phase');
|
||||
const d = await r.json();
|
||||
const el = document.getElementById('promoPhaseInfo');
|
||||
if (d.current_phase === 'LUCKY') {
|
||||
el.innerHTML = '<span style="color:#22c55e">🍀 LUCKY</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||
} else if (d.current_phase === 'UNLUCKY') {
|
||||
el.innerHTML = '<span style="color:#ef4444">💀 UNLUCKY</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||
} else {
|
||||
el.innerHTML = '<span style="color:#a3a3a3">⚪ NEUTRAL</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', loadPromoPhase);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
{% endblock %}
|
||||
{% block top_actions %}
|
||||
<a href="/admin/users" class="at-back">← Назад</a>
|
||||
<a href="/admin/user/{{ target_user.id }}/history" class="a-btn" style="margin-left:0.5rem;font-size:0.82rem;">📜 История</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-stats" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="a-stats">
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Баланс</div>
|
||||
<div class="as-value">{{ target_user.balance|money }} ₽</div>
|
||||
@@ -49,6 +50,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPU v2 -->
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">🎲 RPU v2 — подробно</div>
|
||||
<div id="rpuV2Stats" class="a-mb" style="font-size:0.82rem">
|
||||
<div class="a-row a-gap-sm" style="justify-content:space-around;text-align:center;margin-bottom:0.75rem">
|
||||
<div><div class="as-value" id="v2Ceiling" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Потолок / Депнуто</div></div>
|
||||
<div><div class="as-value" id="v2Budget" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Luck Budget</div></div>
|
||||
<div><div class="as-value" id="v2SessionSpent" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Сессия потрачено</div></div>
|
||||
<div><div class="as-value" id="v2HotScore" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Hot Score</div></div>
|
||||
<div><div class="as-value" id="v2PromoPhase" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Промо-фаза</div></div>
|
||||
</div>
|
||||
<div class="a-row a-gap-sm" style="justify-content:space-around;text-align:center">
|
||||
<div><div class="as-value" id="v2Comeback" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Комбек</div></div>
|
||||
<div><div class="as-value" id="v2BreakCount" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Пробитий</div></div>
|
||||
<div><div class="as-value" id="v2LossStreak" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Проигрышей подряд</div></div>
|
||||
<div><div class="as-value" id="v2SessionReset" style="font-size:1.1rem">--</div><div class="as-label" style="font-size:0.65rem">Сброс сессии</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">🔧 Действия</div>
|
||||
@@ -84,6 +105,27 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="a-table-card-view">
|
||||
{% for item in inventory %}
|
||||
<div class="a-table-card">
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">ID</span>
|
||||
<span class="tc-value">{{ item.id }}</span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Название</span>
|
||||
<span class="tc-value">{{ item.market_hash_name[:40] }}{{'…' if item.market_hash_name|length > 40}}</span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Редкость / Float</span>
|
||||
<span class="tc-value">{{ item.rarity }} / {{ "%.4f"|format(item.float_value) }}</span>
|
||||
</div>
|
||||
<div class="tc-actions">
|
||||
<button class="a-btn a-btn-danger a-btn-sm" onclick="deleteItem({{ item.id }})">🗑️ Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -251,6 +293,24 @@ async function loadRPU() {
|
||||
document.getElementById('rpuSpent').textContent = Math.round(d.stats.total_spent).toLocaleString() + ' ₽';
|
||||
document.getElementById('rpuOpened').textContent = d.stats.total_opened.toLocaleString();
|
||||
document.getElementById('rpuAutoStatus').textContent = d.auto_adjust ? '✅' : '❌';
|
||||
|
||||
// ── RPU v2 ──
|
||||
if (d.v2) {
|
||||
const v = d.v2;
|
||||
document.getElementById('v2Ceiling').textContent = Math.round(v.ceiling.current_ceiling).toLocaleString('ru') + '₽ / ' + Math.round(v.ceiling.total_deposited).toLocaleString('ru') + '₽';
|
||||
document.getElementById('v2Budget').textContent = v.session.luck_budget + '%';
|
||||
document.getElementById('v2Budget').style.color = v.session.luck_budget < 30 ? '#ef4444' : v.session.luck_budget > 70 ? '#22c55e' : '#f59e0b';
|
||||
document.getElementById('v2SessionSpent').textContent = Math.round(v.session.session_spent).toLocaleString('ru') + '₽';
|
||||
document.getElementById('v2HotScore').textContent = v.hot_cold.hot_score;
|
||||
document.getElementById('v2HotScore').style.color = v.hot_cold.hot_score > 0.5 ? '#22c55e' : v.hot_cold.hot_score < -0.5 ? '#ef4444' : '#f59e0b';
|
||||
const ph = v.promo_phase;
|
||||
document.getElementById('v2PromoPhase').textContent = ph.phase;
|
||||
document.getElementById('v2PromoPhase').style.color = ph.phase === 'LUCKY' ? '#22c55e' : ph.phase === 'UNLUCKY' ? '#ef4444' : '#f59e0b';
|
||||
document.getElementById('v2Comeback').textContent = v.comeback.active ? '✅ x' + v.comeback.multiplier.toFixed(1) + ' (' + v.comeback.openings_left + ')' : '❌';
|
||||
document.getElementById('v2BreakCount').textContent = v.ceiling.break_count + ' (' + v.ceiling.break_session + ' сесс)';
|
||||
document.getElementById('v2LossStreak').textContent = v.comeback.consecutive_loss_count + ' (' + Math.round(v.comeback.consecutive_loss_value).toLocaleString('ru') + '₽)';
|
||||
document.getElementById('v2SessionReset').textContent = v.session.reset_date ? new Date(v.session.reset_date).toLocaleString('ru') : '—';
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}История {{ target_user.username }} — Админ-панель{% endblock %}
|
||||
{% block nav_users %}active{% endblock %}
|
||||
{% block page_title %}
|
||||
📜 История {{ target_user.username }}
|
||||
<span class="at-sub">ID {{ target_user.id }}</span>
|
||||
{% endblock %}
|
||||
{% block top_actions %}
|
||||
<a href="/admin/user/{{ target_user.id }}" class="at-back">← Профиль</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.rarity-Covert, .rarity-Rare\ Special\ Item, .rarity-Extraordinary { color: #eb4b4b; }
|
||||
.rarity-Classified { color: #d32ce6; }
|
||||
.rarity-Restricted { color: #8847ff; }
|
||||
.rarity-Mil-Spec, .rarity-Mil-Spec\ Grade { color: #4b69ff; }
|
||||
.rarity-Industrial\ Grade { color: #5e98d9; }
|
||||
.rarity-Consumer\ Grade { color: #b0b0b0; }
|
||||
.mono { font-family: monospace; font-size: 0.72rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-stats" style="margin-bottom:1rem">
|
||||
<div class="a-stat"><div class="as-label">Потолок</div><div class="as-value">{{ "%.0f"|format(ceiling) }} ₽</div></div>
|
||||
<div class="a-stat"><div class="as-label">Текущая ценность</div><div class="as-value">{{ "%.0f"|format(total_value) }} ₽</div></div>
|
||||
<div class="a-stat"><div class="as-label">Luck budget</div><div class="as-value">{{ "%.1f"|format(rpu.luck_budget) }}%</div></div>
|
||||
<div class="a-stat"><div class="as-label">Hot score</div><div class="as-value">{{ "%.0f"|format(rpu.hot_score or 0) }}</div></div>
|
||||
<div class="a-stat"><div class="as-label">Ceiling mult</div><div class="as-value">{{ "%.2f"|format(rpu.ceiling_multiplier) }}</div></div>
|
||||
<div class="a-stat"><div class="as-label">Комбек</div><div class="as-value">{{ rpu.comeback_active }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="a-flex a-mb" style="gap:0.5rem">
|
||||
<button class="a-btn a-btn-primary tab-btn active" onclick="switchTab('openings')">📦 Открытия ({{ openings|length }})</button>
|
||||
<button class="a-btn tab-btn" onclick="switchTab('upgrades')">⬆️ Апгрейды ({{ upgrades|length }})</button>
|
||||
<button class="a-btn tab-btn" onclick="switchTab('transactions')">💳 Транзакции ({{ transactions|length }})</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-openings" class="tab-content" style="display:block">
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>ID</th><th>Дата</th><th>Кейс</th><th>Предмет</th><th>Редкость</th><th>Float</th></tr></thead>
|
||||
<tbody>
|
||||
{% for o in openings %}
|
||||
<tr>
|
||||
<td class="mono">{{ o.id }}</td>
|
||||
<td>{{ o.opened_at.strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ o.case_name }}</td>
|
||||
<td>{{ o.item_name }}</td>
|
||||
<td><span class="rarity-{{ o.rarity|replace(' ', '\\ ') }}">{{ o.rarity }}</span></td>
|
||||
<td>{{ "%.4f"|format(o.float_value) if o.float_value else '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="a-empty" style="padding:2rem">Нет открытий</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-upgrades" class="tab-content" style="display:none">
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>ID</th><th>Дата</th><th>Вход</th><th>Цель</th><th>Результат</th><th>Базовый шанс</th><th>RPU шанс</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in upgrades %}
|
||||
<tr>
|
||||
<td class="mono">{{ u.id }}</td>
|
||||
<td>{{ u.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ u.input_item_name }}</td>
|
||||
<td>{{ u.target_item_name }}</td>
|
||||
<td style="color:{% if u.success %}#22c55e{% else %}#ef4444{% endif %}">{% if u.success %}✅ Успех{% else %}❌ Провал{% endif %}</td>
|
||||
<td>{{ "%.1f"|format(u.probability * 100 if u.probability else 0) }}%</td>
|
||||
<td>{{ "%.1f"|format(u.rpu_adjusted_probability * 100 if u.rpu_adjusted_probability else 0) }}%</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="a-empty" style="padding:2rem">Нет апгрейдов</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-transactions" class="tab-content" style="display:none">
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>ID</th><th>Дата</th><th>Тип</th><th>Сумма</th><th>Комиссия</th><th>Детали</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in transactions %}
|
||||
<tr>
|
||||
<td class="mono">{{ t.id }}</td>
|
||||
<td>{{ t.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% if t.tx_type == 'card_to_site' %}<span class="a-badge a-badge-success">Депозит</span>
|
||||
{% elif t.tx_type == 'site_to_card' %}<span class="a-badge a-badge-banned">Вывод</span>
|
||||
{% elif t.tx_type == 'promo_bonus' %}<span class="a-badge" style="background:rgba(234,179,8,0.12);color:#eab308">Промо</span>
|
||||
{% else %}<span class="a-badge a-badge-user">{{ t.tx_type }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ "%.0f"|format(t.amount) }} ₽</td>
|
||||
<td>{% if t.fee %}{{ "%.0f"|format(t.fee) }} ₽{% else %}—{% endif %}</td>
|
||||
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ t.details or '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="a-empty" style="padding:2rem">Нет транзакций</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
|
||||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).style.display = 'block';
|
||||
document.querySelector('.tab-btn[onclick="switchTab(\'' + name + '\')"]').classList.add('active');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,8 +3,8 @@
|
||||
{% block nav_users %}active{% endblock %}
|
||||
{% block page_title %}👥 Пользователи{% endblock %}
|
||||
{% block top_actions %}
|
||||
<div class="a-flex">
|
||||
<input type="text" id="userSearch" class="a-input a-input-sm" style="width:200px" placeholder="Поиск по имени..." value="{{ search or '' }}">
|
||||
<div class="search-form a-flex">
|
||||
<input type="text" id="userSearch" class="a-input a-input-sm" placeholder="Поиск по имени..." value="{{ search or '' }}">
|
||||
<button class="a-btn a-btn-primary" onclick="searchUsers()">🔍 Найти</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -50,6 +50,40 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="a-table-card-view">
|
||||
{% for u in users %}
|
||||
<div class="a-table-card">
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">ID</span>
|
||||
<span class="tc-value">{{ u.id }}</span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Имя</span>
|
||||
<span class="tc-value"><a href="/admin/user/{{ u.id }}" style="color:#f59e0b;text-decoration:none;font-weight:500">{{ u.username }}</a></span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Баланс</span>
|
||||
<span class="tc-value">{{ u.balance|money }} ₽</span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Статус</span>
|
||||
<span class="tc-value">
|
||||
{% if u.is_admin %}<span class="a-badge a-badge-admin">Админ</span>{% endif %}
|
||||
{% if u.is_banned %}<span class="a-badge a-badge-banned">Забанен</span>{% endif %}
|
||||
{% if not u.is_admin and not u.is_banned %}<span class="a-badge a-badge-user">Игрок</span>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tc-row">
|
||||
<span class="tc-label">Кейсов / Контр / Предм</span>
|
||||
<span class="tc-value">{{ u.case_openings }} / {{ u.contracts }} / {{ u.inventory_count }}</span>
|
||||
</div>
|
||||
<div class="tc-actions">
|
||||
<a href="/admin/user/{{ u.id }}" class="a-btn a-btn-sm">✏️ Редактировать</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="a-empty">
|
||||
|
||||
+29
-10
@@ -519,9 +519,10 @@
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
userBalance = data.balance;
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.balance)} ₽`;
|
||||
});
|
||||
const el = document.getElementById('siteBalanceDisplay');
|
||||
if (el) {
|
||||
el.innerHTML = Math.floor(data.balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
|
||||
}
|
||||
updatePriceDisplay();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1091,7 +1092,7 @@
|
||||
const inventoryIds = [];
|
||||
for (const trashItem of trashItems) {
|
||||
const invItem = items.find(i => i.item_id === trashItem.id &&
|
||||
Math.abs(i.float - trashItem.float) < 0.001);
|
||||
Math.abs(i.float - trashItem.float) < 0.01);
|
||||
if (invItem) {
|
||||
inventoryIds.push(invItem.id);
|
||||
}
|
||||
@@ -1101,10 +1102,28 @@
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(inventoryIds));
|
||||
|
||||
await fetch('/web/api/inventory/sell/bulk', {
|
||||
const res = await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const retry = await fetch('/web/api/inventory/items');
|
||||
const items2 = await retry.json();
|
||||
const retryIds = [];
|
||||
for (const trashItem of trashItems) {
|
||||
const invItem = items2.find(i => i.item_id === trashItem.id &&
|
||||
Math.abs(i.float - trashItem.float) < 0.01);
|
||||
if (invItem && !inventoryIds.includes(invItem.id)) {
|
||||
retryIds.push(invItem.id);
|
||||
}
|
||||
}
|
||||
if (retryIds.length > 0) {
|
||||
const fd2 = new FormData();
|
||||
fd2.append('inventory_ids', JSON.stringify(retryIds));
|
||||
await fetch('/web/api/inventory/sell/bulk', { method: 'POST', body: fd2 });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка продажи шлака:', e);
|
||||
@@ -1702,7 +1721,7 @@
|
||||
</div>
|
||||
<div class="result-footer-enhanced">
|
||||
<span class="result-price-enhanced">${(item.price || 100).toLocaleString()} ₽</span>
|
||||
<button class="btn-sell-result" onclick="sellResultItem(${item.id})">Продать</button>
|
||||
<button class="btn-sell-result" onclick="sellResultItem(${item.id}, ${item.float})">Продать</button>
|
||||
</div>
|
||||
</div>
|
||||
${item.count > 1 ? `<span class="result-count-badge">x${item.count}</span>` : ''}
|
||||
@@ -1724,13 +1743,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function sellResultItem(itemId) {
|
||||
async function sellResultItem(itemId, itemFloat) {
|
||||
Notify.confirm('Продать этот предмет?', 'Продажа', async function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/items');
|
||||
const items = await response.json();
|
||||
const inventoryItem = items.find(i => i.item_id === itemId);
|
||||
const inventoryItem = items.find(i => i.item_id === itemId &&
|
||||
Math.abs(i.float - (itemFloat || 0)) < 0.001);
|
||||
|
||||
if (!inventoryItem) {
|
||||
Notify.error('Предмет не найден в инвентаре');
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,85 @@
|
||||
.btn-open:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.promo-wrapper {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #2d1b3d 50%, #1a1a2e 100%);
|
||||
border: 1px solid rgba(255,215,0,0.2);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: 0 0 20px rgba(255,215,0,0.05);
|
||||
}
|
||||
.promo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.promo-info { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.promo-info--card { text-align: center; }
|
||||
.promo-info--label {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
line-height: 1;
|
||||
}
|
||||
.promo-info--label-bottom {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.promo-timer--inner { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.promo-timer--label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.promo-timer--countdown {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.promo-code { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.promo-code--label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.promo-code--label-light { color: #ffd700; font-weight: 600; }
|
||||
.promo-code--button {
|
||||
background: linear-gradient(135deg, #ffd700, #f59e0b);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
color: #1a1a2e;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.promo-code--button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255,215,0,0.4);
|
||||
}
|
||||
.promo-code--button:active { transform: translateY(0); }
|
||||
.promo-code--button .inner { position: relative; z-index: 2; }
|
||||
.promo-code--button.copied {
|
||||
background: linear-gradient(135deg, #16a34a, #15803d);
|
||||
color: white;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.promo-container { flex-direction: column; align-items: stretch; text-align: center; }
|
||||
.promo-info { justify-content: center; }
|
||||
.promo-timer--inner { justify-content: center; }
|
||||
.promo-code { justify-content: center; flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -162,6 +241,33 @@
|
||||
<h1>📦 Кейсы</h1>
|
||||
<p class="page-subtitle">Выберите кейс для открытия</p>
|
||||
|
||||
{% if auto_promo and user %}
|
||||
<section class="promo-wrapper" id="promocode-holder">
|
||||
<div class="promo-container">
|
||||
<div class="promo-info">
|
||||
<div class="promo-info--card">
|
||||
<div class="promo-info--label ff-secondary">+{{ auto_promo.reward_amount }}%</div>
|
||||
<div class="promo-info--label-bottom">НА СЧЕТ</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="promo-timer">
|
||||
<div class="promo-timer--inner">
|
||||
<p class="promo-timer--label">ОСТАЛОСЬ ВРЕМЕНИ:</p>
|
||||
<span class="ff-secondary promo-timer--countdown" id="promocode-countdown" data-seconds="{{ auto_promo.expires_in_seconds }}">
|
||||
<span>00</span>:<span>00</span>:<span>00</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="promo-code" id="coupon-holder" data-promocode="{{ auto_promo.code }}">
|
||||
<p class="promo-code--label">ИСПОЛЬЗУЙ <span class="promo-code--label-light">ПРОМОКОД:</span></p>
|
||||
<button class="promo-code--button point-btn" onclick="copyAndOpenPromo('{{ auto_promo.code }}', this)">
|
||||
<span class="inner">{{ auto_promo.code }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="cases-sections">
|
||||
{% for section in sections %}
|
||||
<div class="section-block">
|
||||
@@ -207,6 +313,47 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Промо-баннер таймер
|
||||
(function() {
|
||||
var el = document.getElementById('promocode-countdown');
|
||||
if (!el) return;
|
||||
var total = parseInt(el.dataset.seconds, 10) || 0;
|
||||
var spans = el.querySelectorAll('span');
|
||||
function tick() {
|
||||
if (total <= 0) { spans[0].textContent = '00'; spans[1].textContent = '00'; spans[2].textContent = '00'; return; }
|
||||
total--;
|
||||
var h = Math.floor(total / 3600);
|
||||
var m = Math.floor((total % 3600) / 60);
|
||||
var s = total % 60;
|
||||
spans[0].textContent = String(h).padStart(2, '0');
|
||||
spans[1].textContent = String(m).padStart(2, '0');
|
||||
spans[2].textContent = String(s).padStart(2, '0');
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
})();
|
||||
function copyAndOpenPromo(code, btn) {
|
||||
if (!btn) btn = document.querySelector('.promo-code--button');
|
||||
navigator.clipboard.writeText(code).then(function() {
|
||||
btn.classList.add('copied');
|
||||
btn.innerHTML = '<span class="inner">✓ СКОПИРОВАНО</span>';
|
||||
setTimeout(function() {
|
||||
btn.classList.remove('copied');
|
||||
btn.innerHTML = '<span class="inner">' + code + '</span>';
|
||||
}, 2000);
|
||||
}).catch(function() {});
|
||||
// Открываем депозит-попап и вставляем код
|
||||
var overlay = document.getElementById('depositOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.add('open');
|
||||
var input = document.getElementById('promoInput');
|
||||
if (input) { input.value = code; input.focus(); }
|
||||
// Грузим балансы
|
||||
if (typeof loadDepositData === 'function') loadDepositData();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
async function logout() {
|
||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||||
|
||||
@@ -685,7 +685,7 @@
|
||||
|
||||
function handleAchievements(d) {
|
||||
if(d && d.achievements_unlocked && Array.isArray(d.achievements_unlocked) && d.achievements_unlocked.length)
|
||||
d.achievements_unlocked.forEach(a => showAchievementPopup(a.title || a.name || 'Достижение', a.reward || ''));
|
||||
d.achievements_unlocked.forEach(a => showAchievementPopup(a.title || a.name || 'Достижение'));
|
||||
}
|
||||
function toggleSound() {
|
||||
const e = SoundManager.toggle();
|
||||
|
||||
@@ -445,9 +445,10 @@
|
||||
myActiveBet = data.bet;
|
||||
myCashoutMult = null;
|
||||
SoundManager.crashBet();
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.new_balance)} ₽`;
|
||||
});
|
||||
const el = document.getElementById('siteBalanceDisplay');
|
||||
if (el) {
|
||||
el.innerHTML = Math.floor(data.new_balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
|
||||
}
|
||||
updateUI();
|
||||
} else {
|
||||
SoundManager.error();
|
||||
@@ -468,9 +469,10 @@
|
||||
if (data.success) {
|
||||
myCashoutMult = data.multiplier;
|
||||
SoundManager.crashCashout();
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.new_balance)} ₽`;
|
||||
});
|
||||
const el = document.getElementById('siteBalanceDisplay');
|
||||
if (el) {
|
||||
el.innerHTML = Math.floor(data.new_balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
|
||||
}
|
||||
updateUI();
|
||||
} else {
|
||||
SoundManager.error();
|
||||
|
||||
+162
-10
@@ -140,6 +140,32 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.withdraw-selected-btn {
|
||||
padding: 0.45rem 1.25rem;
|
||||
background: var(--success-color);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
transition: all 0.2s;
|
||||
font-family: 'Russo One', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.withdraw-selected-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.withdraw-selected-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sell-all-btn {
|
||||
padding: 0.45rem 1.25rem;
|
||||
background: transparent;
|
||||
@@ -234,6 +260,25 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-withdraw-single {
|
||||
flex: 1;
|
||||
padding: 0.3rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--success-color);
|
||||
color: var(--success-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: all 0.2s;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-withdraw-single:hover {
|
||||
background: var(--success-color);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -628,6 +673,7 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;">
|
||||
<button class="sell-selected-btn" id="sellSelectedBtn" onclick="sellSelected()" disabled>💰 Продать</button>
|
||||
<button class="withdraw-selected-btn" id="withdrawSelectedBtn" onclick="withdrawSelected()" disabled>💳 Вывести</button>
|
||||
<button class="sell-all-btn" onclick="openBulkSellModal()">📦 Массовая</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -658,12 +704,12 @@
|
||||
data-rarity="{{ item.rarity }}"
|
||||
data-type="{{ item.type }}"
|
||||
data-name="{{ item.name|lower }}"
|
||||
data-price="{{ item.price_rub or 100 }}">
|
||||
data-price="{{ item.price_rub }}">
|
||||
{% if not is_public %}
|
||||
<input type="checkbox" class="item-select-checkbox"
|
||||
onchange="onItemSelect(this)"
|
||||
data-id="{{ item.id }}"
|
||||
data-price="{{ item.price_rub or 100 }}">
|
||||
data-price="{{ item.price_rub }}">
|
||||
{% endif %}
|
||||
<div class="skin-image-container">
|
||||
<img src="{{ item.image_url or '/static/placeholder.png' }}"
|
||||
@@ -681,11 +727,12 @@
|
||||
<span>{{ item.wear }}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top:3px;color:var(--success-color);">
|
||||
<span>💰 {{ (item.price_rub or 100)|money }} ₽</span>
|
||||
<span>💰 {{ (item.price_rub)|money }} ₽</span>
|
||||
</div>
|
||||
{% if not is_public %}
|
||||
<div class="item-actions">
|
||||
<button onclick="sellSingleItem({{ item.id }})" class="btn-sell-single">Продать</button>
|
||||
<button onclick="withdrawSingleItem({{ item.id }}, {{ (item.price_rub)|int }})" class="btn-withdraw-single">Вывести</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -719,7 +766,8 @@
|
||||
</div>
|
||||
<div class="bulk-sell-actions">
|
||||
<button class="btn btn-outline" onclick="closeBulkSellModal()">Отмена</button>
|
||||
<button class="btn btn-primary" onclick="executeBulkSell()" style="background:var(--danger-color);">Продать</button>
|
||||
<button class="btn btn-primary" onclick="executeBulkSell()" style="background:var(--danger-color);">💰 Продать</button>
|
||||
<button class="btn btn-primary" onclick="executeBulkWithdraw()" style="background:var(--success-color);color:#000;">💳 Вывести</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -826,6 +874,7 @@
|
||||
const countBadge = document.getElementById('selectedCountBadge');
|
||||
const priceBadge = document.getElementById('totalPriceBadge');
|
||||
const sellBtn = document.getElementById('sellSelectedBtn');
|
||||
const withdrawBtn = document.getElementById('withdrawSelectedBtn');
|
||||
if (count > 0) {
|
||||
countBadge.style.display = 'inline-block';
|
||||
countBadge.textContent = count + ' выбрано';
|
||||
@@ -834,10 +883,12 @@
|
||||
priceBadge.style.display = 'inline-block';
|
||||
priceBadge.textContent = total.toFixed(0) + ' ₽';
|
||||
sellBtn.disabled = false;
|
||||
withdrawBtn.disabled = false;
|
||||
} else {
|
||||
countBadge.style.display = 'none';
|
||||
priceBadge.style.display = 'none';
|
||||
sellBtn.disabled = true;
|
||||
withdrawBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,9 +897,10 @@
|
||||
const r = await fetch('/web/api/user/balance');
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = Math.floor(d.balance) + ' ₽';
|
||||
});
|
||||
const el = document.getElementById('siteBalanceDisplay');
|
||||
if (el) {
|
||||
el.innerHTML = Math.floor(d.balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
@@ -906,6 +958,64 @@
|
||||
});
|
||||
}
|
||||
|
||||
function withdrawSingleItem(inventoryId, price) {
|
||||
Notify.confirm('Вывести ' + (price||'').toLocaleString() + ' ₽ на карту? (Комиссия 10%)', 'Вывод', function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
SoundManager.click();
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_item_id', inventoryId);
|
||||
fetch('/web/api/withdraw', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
SoundManager.balanceUpdate();
|
||||
Notify.success('Выведено ' + data.net.toFixed(0) + ' ₽ на карту');
|
||||
const card = document.querySelector('.inventory-skin-card[data-inventory-id="' + inventoryId + '"]');
|
||||
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; setTimeout(() => card.remove(), 300); }
|
||||
refreshBalance();
|
||||
} else {
|
||||
SoundManager.error();
|
||||
Notify.error(data.error || 'Ошибка при выводе');
|
||||
}
|
||||
})
|
||||
.catch(() => { SoundManager.error(); Notify.error('Ошибка соединения'); });
|
||||
});
|
||||
}
|
||||
|
||||
function withdrawSelected() {
|
||||
if (selectedItems.size === 0) return;
|
||||
let total = 0;
|
||||
selectedItems.forEach(id => { total += itemPrices.get(id) || 100; });
|
||||
const fee = total * 0.1;
|
||||
Notify.confirm('Вывести ' + selectedItems.size + ' предметов на карту?\nСумма: ' + total.toFixed(0) + ' ₽\nКомиссия: ' + fee.toFixed(0) + ' ₽\nК зачислению: ' + (total - fee).toFixed(0) + ' ₽', 'Массовый вывод', function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
SoundManager.click();
|
||||
const ids = Array.from(selectedItems);
|
||||
let completed = 0, errors = 0;
|
||||
ids.forEach(function(invId) {
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_item_id', invId);
|
||||
fetch('/web/api/withdraw', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
completed++;
|
||||
const card = document.querySelector('.inventory-skin-card[data-inventory-id="' + invId + '"]');
|
||||
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; setTimeout(() => card.remove(), 300); }
|
||||
} else {
|
||||
errors++;
|
||||
}
|
||||
if (completed + errors === ids.length) {
|
||||
Notify.success('Выведено ' + completed + ' предметов' + (errors ? ', ошибок: ' + errors : ''));
|
||||
selectedItems.clear();
|
||||
refreshBalance();
|
||||
}
|
||||
})
|
||||
.catch(function() { errors++; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openBulkSellModal() {
|
||||
const modal = document.getElementById('bulkSellModal');
|
||||
const optionsDiv = document.getElementById('bulkSellOptions');
|
||||
@@ -997,6 +1107,45 @@
|
||||
});
|
||||
}
|
||||
|
||||
function executeBulkWithdraw() {
|
||||
const selected = [];
|
||||
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => { if (cb.value && cb.value !== 'all') selected.push(cb.value); });
|
||||
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
|
||||
const ids = [];
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
if (allChecked || selected.includes(card.dataset.rarity)) {
|
||||
ids.push(parseInt(card.dataset.inventoryId));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (ids.length === 0) { Notify.error('Выберите хотя бы одну редкость'); return; }
|
||||
let totalPrice = 0;
|
||||
ids.forEach(id => { totalPrice += itemPrices.get(id) || 100; });
|
||||
const fee = totalPrice * 0.1;
|
||||
Notify.confirm('Вывести ' + ids.length + ' предметов на карту?\nСумма: ' + totalPrice.toFixed(0) + ' ₽\nКомиссия: ' + fee.toFixed(0) + ' ₽\nК зачислению: ' + (totalPrice - fee).toFixed(0) + ' ₽', 'Массовый вывод', function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
let completed = 0, errors = 0;
|
||||
ids.forEach(function(invId) {
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_item_id', invId);
|
||||
fetch('/web/api/withdraw', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) { completed++; }
|
||||
else { errors++; }
|
||||
if (completed + errors === ids.length) {
|
||||
Notify.success('Выведено ' + completed + ' предметов' + (errors ? ', ошибок: ' + errors : ''));
|
||||
refreshBalance();
|
||||
closeBulkSellModal();
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() { errors++; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filterItems() {
|
||||
const rf = document.getElementById('rarityFilter').value.toLowerCase();
|
||||
const tf = document.getElementById('typeFilter').value.toLowerCase();
|
||||
@@ -1071,7 +1220,7 @@
|
||||
+ '<div class="skin-details"><span class="rarity-' + rc + '">' + (item.rarity||'') + '</span><span>' + fs + '</span></div>'
|
||||
+ '<div class="skin-details" style="margin-top:3px;"><span>' + (item.type||'') + '</span><span>' + (item.wear||'') + '</span></div>'
|
||||
+ '<div class="skin-details" style="margin-top:3px;color:var(--success-color);"><span>💰 ' + ((item.price_rub||100)).toLocaleString() + ' ₽</span></div>'
|
||||
+ '<div class="item-actions"><button onclick="sellSingleItem(' + item.id + ')" class="btn-sell-single">Продать</button></div></div></div>';
|
||||
+ '<div class="item-actions"><button onclick="sellSingleItem(' + item.id + ')" class="btn-sell-single">Продать</button><button onclick="withdrawSingleItem(' + item.id + ', ' + (item.price_rub||100) + ')" class="btn-withdraw-single">Вывести</button></div></div></div>';
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
selectedItems.clear();
|
||||
@@ -1096,7 +1245,7 @@
|
||||
grid.addEventListener('click', function(e) {
|
||||
const card = e.target.closest('.inventory-skin-card');
|
||||
if (!card) return;
|
||||
if (e.target.closest('.item-select-checkbox, .btn-sell-single')) return;
|
||||
if (e.target.closest('.item-select-checkbox, .btn-sell-single, .btn-withdraw-single')) return;
|
||||
const invId = card.dataset.inventoryId;
|
||||
if (!invId) return;
|
||||
openItemCard(invId);
|
||||
@@ -1155,7 +1304,10 @@
|
||||
+ '<div class="ic-detail"><span class="ic-label">Тип</span><span class="ic-value">' + (item.type || 'Normal') + '</span></div>'
|
||||
+ '<div class="ic-detail"><span class="ic-label">Цена</span><span class="ic-value" style="color:var(--success-color)">💰 ' + (item.price_rub || 0).toLocaleString() + ' ₽</span></div>'
|
||||
+ (collHtml ? '<div class="ic-collection-title">🎨 Скины коллекции</div><div class="ic-collection-list">' + collHtml + '</div>' : '')
|
||||
+ '<button class="btn-sell-single ic-sell-btn" onclick="sellSingleItem(' + item.id + '); closeItemCard();">Продать за ' + (item.price_rub || 0).toLocaleString() + ' ₽</button>'
|
||||
+ '<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">'
|
||||
+ '<button class="ic-sell-btn" style="flex:1;" onclick="sellSingleItem(' + item.id + '); closeItemCard();">Продать за ' + (item.price_rub || 0).toLocaleString() + ' ₽</button>'
|
||||
+ '<button class="ic-sell-btn" style="flex:1;background:var(--success-color);color:#000;" onclick="withdrawSingleItem(' + item.id + ', ' + (item.price_rub || 100) + '); closeItemCard();">Вывести</button>'
|
||||
+ '</div>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
|
||||
+14
-8
@@ -517,9 +517,10 @@
|
||||
const r = await fetch('/web/api/user/balance');
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(d.balance).toLocaleString()} ₽`;
|
||||
});
|
||||
const el = document.getElementById('siteBalanceDisplay');
|
||||
if (el) {
|
||||
el.innerHTML = Math.floor(d.balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -609,6 +610,8 @@
|
||||
let tickLastDeg = 0, tickCumulative = 0, tickNextThreshold = 15;
|
||||
let tickLastTickCumulative = 0;
|
||||
let tickControlPlayed = false;
|
||||
let lastTickSoundTime = 0;
|
||||
const TICK_SOUND_COOLDOWN = 60;
|
||||
const tickSlowStart = data.final_angle - 90;
|
||||
const tickStartTime = performance.now();
|
||||
const tickMaxDuration = spinDuration * 1000 + 500;
|
||||
@@ -634,16 +637,18 @@
|
||||
if (delta < -180) delta += 360;
|
||||
tickCumulative += delta;
|
||||
tickLastDeg = deg;
|
||||
if (tickCumulative >= tickNextThreshold) {
|
||||
const now = performance.now();
|
||||
if (tickCumulative >= tickNextThreshold && now - lastTickSoundTime >= TICK_SOUND_COOLDOWN) {
|
||||
SoundManager.wheelSpin();
|
||||
lastTickSoundTime = now;
|
||||
tickLastTickCumulative = tickCumulative;
|
||||
tickNextThreshold += tickCumulative < tickSlowStart ? 15 : 30;
|
||||
}
|
||||
// Контрольный тик: если до финала <10°, а последний тик был >15° назад
|
||||
const remaining = data.final_angle - tickCumulative;
|
||||
const gap = tickCumulative - tickLastTickCumulative;
|
||||
if (remaining < 10 && remaining > 0 && gap > 15 && !tickControlPlayed) {
|
||||
if (remaining < 10 && remaining > 0 && gap > 15 && !tickControlPlayed && now - lastTickSoundTime >= TICK_SOUND_COOLDOWN) {
|
||||
SoundManager.wheelSpin();
|
||||
lastTickSoundTime = now;
|
||||
tickControlPlayed = true;
|
||||
}
|
||||
tickRAF = requestAnimationFrame(tickLoop);
|
||||
@@ -661,6 +666,7 @@
|
||||
const showResult = () => {
|
||||
if (resultShown) return;
|
||||
resultShown = true;
|
||||
SoundManager.stopAll();
|
||||
if (data.upgrade_success && data.received_item) {
|
||||
document.getElementById('displayTargetImage').src = data.received_item.image_url || '/static/placeholder.png';
|
||||
document.getElementById('displayTargetName').textContent = data.received_item.name.substring(0, 25);
|
||||
@@ -735,7 +741,7 @@
|
||||
item_id: item.item_id,
|
||||
name: item.name,
|
||||
rarity: item.rarity,
|
||||
price_rub: item.price || item.price_rub || 100,
|
||||
price_rub: item.price || item.price_rub || 0,
|
||||
image_url: img,
|
||||
wear: item.wear || '',
|
||||
float: item.float || item.float_value || 0
|
||||
@@ -764,7 +770,7 @@
|
||||
data.achievements_unlocked.forEach(ach => {
|
||||
const title = ach.title || ach.name || 'Достижение';
|
||||
const reward = ach.reward || '';
|
||||
showAchievementPopup(title, reward);
|
||||
showAchievementPopup(title);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+47
-7
@@ -4,16 +4,22 @@ from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from database import User, UpgradeRPU, Upgrade
|
||||
|
||||
MAX_WIN_STREAK = 3
|
||||
MAX_LOSE_STREAK = 5
|
||||
|
||||
|
||||
def get_upgrade_rpu(db: Session, user_id: int) -> UpgradeRPU:
|
||||
"""Получает или создает настройки РПУ апгрейдов"""
|
||||
rpu = db.query(UpgradeRPU).filter(UpgradeRPU.user_id == user_id).first()
|
||||
if not rpu:
|
||||
rpu = UpgradeRPU(user_id=user_id)
|
||||
db.add(rpu)
|
||||
db.commit()
|
||||
from rpu import _rpu_commit_or_flush
|
||||
_rpu_commit_or_flush(db)
|
||||
db.refresh(rpu)
|
||||
return rpu
|
||||
|
||||
|
||||
def calculate_upgrade_probability(input_price: float, target_price: float) -> float:
|
||||
if input_price <= 0 or target_price <= 0:
|
||||
return 50.0
|
||||
@@ -31,16 +37,28 @@ def get_adjusted_upgrade_probability(db: Session, user_id: int, base_probability
|
||||
return -1.0
|
||||
|
||||
rpu = get_upgrade_rpu(db, user_id)
|
||||
|
||||
# Базовая корректировка от множителя
|
||||
adjusted = base_probability * rpu.upgrade_multiplier
|
||||
|
||||
# Корректировка на серии
|
||||
if rpu.current_win_streak >= MAX_WIN_STREAK:
|
||||
# Слишком много побед подряд — режем шанс
|
||||
penalty = (rpu.current_win_streak - MAX_WIN_STREAK + 1) * 0.15
|
||||
adjusted *= (1.0 - penalty)
|
||||
elif rpu.current_lose_streak >= MAX_LOSE_STREAK:
|
||||
# Слишком много поражений подряд — повышаем шанс
|
||||
boost = (rpu.current_lose_streak - MAX_LOSE_STREAK + 1) * 0.2
|
||||
adjusted *= (1.0 + boost)
|
||||
|
||||
return max(1.0, min(75.0, adjusted))
|
||||
|
||||
|
||||
def spin_upgrade_wheel(success_probability: float) -> tuple[bool, float, int, float]:
|
||||
raw_angle = random.uniform(0, 360)
|
||||
|
||||
half_green = (success_probability / 100) * 180
|
||||
|
||||
# Green zone: [180 - half_green, 180 + half_green] — centered at 180° (bottom)
|
||||
# Матчим CSS конусный градиент: from calc(180deg - prob * 3.6deg / 2)
|
||||
success = 180 - half_green <= raw_angle <= 180 + half_green
|
||||
|
||||
margin = max(3, half_green * 0.12)
|
||||
@@ -73,19 +91,41 @@ def spin_upgrade_wheel(success_probability: float) -> tuple[bool, float, int, fl
|
||||
|
||||
return success, final_angle, rotations, raw_angle
|
||||
|
||||
|
||||
def update_upgrade_stats(db: Session, user_id: int, success: bool, input_price: float):
|
||||
rpu = get_upgrade_rpu(db, user_id)
|
||||
rpu.total_attempts += 1
|
||||
|
||||
if success:
|
||||
rpu.total_success += 1
|
||||
rpu.current_win_streak += 1
|
||||
rpu.current_lose_streak = 0
|
||||
if rpu.current_win_streak > rpu.best_win_streak:
|
||||
rpu.best_win_streak = rpu.current_win_streak
|
||||
else:
|
||||
rpu.total_spent_value += input_price
|
||||
rpu.current_lose_streak += 1
|
||||
rpu.current_win_streak = 0
|
||||
if rpu.current_lose_streak > rpu.worst_lose_streak:
|
||||
rpu.worst_lose_streak = rpu.current_lose_streak
|
||||
|
||||
if rpu.auto_adjust and rpu.total_attempts >= 15:
|
||||
# Авто-подкрутка
|
||||
if rpu.auto_adjust and rpu.total_attempts >= 10:
|
||||
# Анализ серии побед
|
||||
if rpu.current_win_streak >= MAX_WIN_STREAK:
|
||||
rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.15)
|
||||
|
||||
# Анализ серии поражений
|
||||
if rpu.current_lose_streak >= MAX_LOSE_STREAK:
|
||||
rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.2)
|
||||
|
||||
# Общий процент побед
|
||||
rate = rpu.total_success / rpu.total_attempts
|
||||
if rate < 0.2:
|
||||
rpu.upgrade_multiplier = min(2.0, rpu.upgrade_multiplier + 0.1)
|
||||
rpu.upgrade_multiplier = min(2.5, rpu.upgrade_multiplier + 0.1)
|
||||
elif rate > 0.6:
|
||||
rpu.upgrade_multiplier = max(0.5, rpu.upgrade_multiplier - 0.1)
|
||||
rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.1)
|
||||
|
||||
from rpu import _rpu_commit_or_flush
|
||||
_rpu_commit_or_flush(db)
|
||||
|
||||
db.commit()
|
||||
Reference in New Issue
Block a user