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:
2026-07-25 13:11:08 +00:00
parent 6638ab99f3
commit 47c7f17c90
30 changed files with 5862 additions and 1295 deletions
+4 -3
View File
@@ -1,6 +1,7 @@
*.db
*.db-shm
*.db-wal
*.bak
__pycache__/
*.pyc
*.pyo
.DS_Store
cs2_simulator.db
*.log
+183 -204
View File
@@ -5,6 +5,43 @@ from datetime import datetime
import json
import math
# Кэш коллекций — строится один раз
_COLLECTION_CACHE = None
def _build_collection_cache():
"""Строит кэш: все базовые имена по коллекциям."""
global _COLLECTION_CACHE
if _COLLECTION_CACHE is not None:
return
from backend import ALL_ITEMS, COLLECTIONS_INDEX, extract_base_name
coll_base_names = {}
coll_item_ids = {}
for coll_name, item_ids in COLLECTIONS_INDEX.items():
base_set = set()
clean_ids = [cid for cid in item_ids if cid < len(ALL_ITEMS)]
for cid in clean_ids:
item = ALL_ITEMS[cid]
base = extract_base_name(item.get("market_hash_name", ""))
if base:
base_set.add(base)
if base_set:
coll_base_names[coll_name] = base_set
coll_item_ids[coll_name] = set(clean_ids)
# Кэшируем custom-флаг на каждую коллекцию
coll_is_custom = {}
for coll_name, item_ids in COLLECTIONS_INDEX.items():
is_custom = False
for cid in item_ids:
if cid < len(ALL_ITEMS) and ALL_ITEMS[cid].get("price_source") == "custom":
is_custom = True
break
coll_is_custom[coll_name] = is_custom
_COLLECTION_CACHE = {
"base_names": coll_base_names,
"item_ids": coll_item_ids,
"is_custom": coll_is_custom,
}
# ─── Определения достижений ─────────────────────────────────────────────────
ACHIEVEMENT_DEFS = [
@@ -1803,126 +1840,92 @@ ACHIEVEMENT_DEFS = [
# ─── Функции проверки достижений ──────────────────────────────────────────
def check_collection_achievements(db: Session, user_id: int):
"""Проверяет достижения, связанные с коллекциями"""
from backend import ALL_ITEMS, COLLECTIONS_INDEX, extract_base_name
"""Проверяет достижения, связанные с коллекциями — использует кэш."""
from database import InventoryItem
_build_collection_cache()
cache = _COLLECTION_CACHE
coll_base_names = cache["base_names"]
coll_item_ids = cache["item_ids"]
coll_is_custom = cache["is_custom"]
results = []
# Все предметы пользователя
user_inv = db.query(InventoryItem.item_id).filter(
# ID предметов пользователя
user_ids = {iid for (iid,) in db.query(InventoryItem.item_id).filter(
InventoryItem.user_id == user_id
).all()
user_ids = {iid for (iid,) in user_inv}
).all()}
# Собираем все коллекции, в которых есть предметы пользователя
# и уникальные базовые имена предметов из коллекций
user_collections = set()
user_coll_base_names = {} # collection_name → set of base names owned
all_coll_base_names = {} # collection_name → set of all base names in collection
for coll_name, item_ids in COLLECTIONS_INDEX.items():
base_set = set()
for cid in item_ids:
item = ALL_ITEMS[cid] if cid < len(ALL_ITEMS) else None
if item:
base = extract_base_name(item.get("market_hash_name", ""))
if base:
base_set.add(base)
if base_set:
all_coll_base_names[coll_name] = base_set
# Check if user owns any item from this collection
owned_bases = set()
for cid in item_ids:
if cid in user_ids:
item = ALL_ITEMS[cid] if cid < len(ALL_ITEMS) else None
if item:
base = extract_base_name(item.get("market_hash_name", ""))
# Коллекции пользователя
from backend import ALL_ITEMS, COLLECTIONS_INDEX, extract_base_name, RARITY_ORDER
user_coll_base_names = {}
for coll_name, item_ids_set in coll_item_ids.items():
owned = user_ids & item_ids_set
if owned:
owned_bases = set()
for cid in owned:
if cid < len(ALL_ITEMS):
base = extract_base_name(ALL_ITEMS[cid].get("market_hash_name", ""))
if base:
owned_bases.add(base)
if owned_bases:
user_collections.add(coll_name)
user_coll_base_names[coll_name] = owned_bases
if owned_bases:
user_coll_base_names[coll_name] = owned_bases
# collection_count: unique collections user has items from
coll_count = len(user_collections)
coll_count = len(user_coll_base_names)
for n in ["coll_first", "coll_5", "coll_10", "coll_25", "coll_50", "coll_75", "coll_100"]:
r = check_achievement(db, user_id, n, coll_count)
if r: results.append(r)
# collection_items: unique base items from collections owned
all_owned_bases = set()
for bases in user_coll_base_names.values():
all_owned_bases.update(bases)
total_unique_items = len(all_owned_bases)
total_unique = len(all_owned_bases)
for n in ["coll_items_10", "coll_items_50", "coll_items_100", "coll_items_250",
"coll_items_500", "coll_items_1000"]:
r = check_achievement(db, user_id, n, total_unique_items)
r = check_achievement(db, user_id, n, total_unique)
if r: results.append(r)
# collection_complete_count: collections where user owns ALL base items
complete_count = 0
for coll_name, all_bases in all_coll_base_names.items():
owned_bases = user_coll_base_names.get(coll_name, set())
if all_bases.issubset(owned_bases):
for coll_name, all_bases in coll_base_names.items():
owned = user_coll_base_names.get(coll_name, set())
if all_bases.issubset(owned):
complete_count += 1
for n in ["coll_complete_1", "coll_complete_5", "coll_complete_10", "coll_complete_25",
"coll_complete_50"]:
r = check_achievement(db, user_id, n, complete_count)
if r: results.append(r)
# coll_all_base: all NON-CUSTOM collections complete
non_custom_collections = {}
for coll_name, item_ids in COLLECTIONS_INDEX.items():
# Check if any item in this collection is custom
is_custom = False
for cid in item_ids:
item = ALL_ITEMS[cid] if cid < len(ALL_ITEMS) else None
if item and item.get("price_source") == "custom":
is_custom = True
break
if not is_custom and coll_name in all_coll_base_names:
non_custom_collections[coll_name] = all_coll_base_names[coll_name]
all_base_complete = True
for coll_name, all_bases in non_custom_collections.items():
owned_bases = user_coll_base_names.get(coll_name, set())
if not all_bases.issubset(owned_bases):
all_base_complete = False
break
if all_base_complete and non_custom_collections:
non_custom = {k: v for k, v in coll_base_names.items() if not coll_is_custom.get(k)}
all_base_ok = all(
non_custom[c].issubset(user_coll_base_names.get(c, set()))
for c in non_custom
)
if all_base_ok and non_custom:
r = check_achievement(db, user_id, "coll_all_base", 1)
if r: results.append(r)
# coll_all: ALL collections complete (including custom)
all_complete = True
for coll_name, all_bases in all_coll_base_names.items():
owned_bases = user_coll_base_names.get(coll_name, set())
if not all_bases.issubset(owned_bases):
all_complete = False
break
if all_complete and all_coll_base_names:
all_ok = all(
coll_base_names[c].issubset(user_coll_base_names.get(c, set()))
for c in coll_base_names
)
if all_ok and coll_base_names:
r = check_achievement(db, user_id, "coll_all", 1)
if r: results.append(r)
# coll_rarity_complete: complete all items of specific rarity tiers in any collection
from backend import RARITY_ORDER
rarity_collectors = [
("coll_cheap_collector", {0}), # Consumer Grade
("coll_mid_collector", {2}), # Mil-Spec / Mil-Spec Grade
("coll_rare_collector", {3, 4, 5, 6, 7, 8}), # Restricted+
("coll_covert_collector", {5}), # Covert
("coll_cheap_collector", {0}),
("coll_mid_collector", {2}),
("coll_rare_collector", {3, 4, 5, 6, 7, 8}),
("coll_covert_collector", {5}),
]
for ach_name, target_levels in rarity_collectors:
found = False
for coll_name in all_coll_base_names:
# Collect all items at target rarity levels for this collection
for coll_name in coll_base_names:
owned = user_coll_base_names.get(coll_name, set())
all_target = set()
owned_target = set()
for cid in COLLECTIONS_INDEX.get(coll_name, []):
item = ALL_ITEMS[cid] if cid < len(ALL_ITEMS) else None
if not item:
if cid >= len(ALL_ITEMS):
continue
item = ALL_ITEMS[cid]
base = extract_base_name(item.get("market_hash_name", ""))
if not base:
continue
@@ -1938,24 +1941,13 @@ def check_collection_achievements(db: Session, user_id: int):
r = check_achievement(db, user_id, ach_name, 1)
if r: results.append(r)
# coll_case_complete: complete all items from a case
for coll_name, all_bases in all_coll_base_names.items():
if not coll_name.startswith("Case:"):
continue
owned_bases = user_coll_base_names.get(coll_name, set())
if all_bases.issubset(owned_bases):
r = check_achievement(db, user_id, "coll_full_case", 1)
if r: results.append(r)
break
# Also check non-case named collections
for coll_name, all_bases in all_coll_base_names.items():
for coll_name in coll_base_names:
if coll_name.startswith("Case:"):
continue
owned_bases = user_coll_base_names.get(coll_name, set())
if all_bases.issubset(owned_bases):
# This also counts for coll_full_case if user wants case completion
pass # Already counted in collection_complete_count above
owned = user_coll_base_names.get(coll_name, set())
if coll_base_names[coll_name].issubset(owned):
r = check_achievement(db, user_id, "coll_full_case", 1)
if r: results.append(r)
break
return results
@@ -2009,15 +2001,10 @@ def check_achievement(db: Session, user_id: int, achievement_name: str, current_
if current_value >= ach.requirement_value:
ua.unlocked_at = datetime.utcnow()
from database import User
user = db.query(User).filter(User.id == user_id).first()
if user:
user.balance += ach.reward_amount
db.commit()
activity = ActivityFeed(
user_id=user_id,
username=user.username if user else "unknown",
username=f"user_{user_id}",
activity_type="achievement",
message=f"🏆 Получено достижение: {ach.title}",
data_json=json.dumps({
@@ -2027,7 +2014,7 @@ def check_achievement(db: Session, user_id: int, achievement_name: str, current_
})
)
db.add(activity)
db.commit()
db.flush()
return {
"name": ach.name,
@@ -2036,10 +2023,11 @@ def check_achievement(db: Session, user_id: int, achievement_name: str, current_
"reward": ach.reward_amount
}
db.commit()
db.flush()
return None
# ─── Существующие проверки (расширенные) ───────────────────────────────────
def check_open_cases_achievements(db: Session, user_id: int):
@@ -2110,31 +2098,19 @@ def check_upgrade_risk_achievements(db: Session, user_id: int, item_price: float
def check_upgrade_result_achievements(db: Session, user_id: int, success: bool, probability: float, item_price: float):
"""Вызывается после завершения апгрейда — проверяет серии, чудеса и т.д."""
from database import Upgrade
from upgrade import get_upgrade_rpu
results = []
# Consecutive wins/losses
all_upgrades = db.query(Upgrade.success).filter(
Upgrade.user_id == user_id
).order_by(Upgrade.id.desc()).all()
# Используем UpgradeRPU.current_win_streak / current_lose_streak вместо загрузки всех апгрейдов
rpu = get_upgrade_rpu(db, user_id)
streak = 0
streak_type = None # True = wins, False = losses
for (s,) in all_upgrades:
if streak_type is None:
streak_type = s
streak = 1
elif s == streak_type:
streak += 1
else:
break
if streak_type == True:
if rpu.current_win_streak > 0:
for n in ["upgrade_consecutive_wins_3", "upgrade_consecutive_wins_5", "upgrade_consecutive_wins_10"]:
r = check_achievement(db, user_id, n, streak)
r = check_achievement(db, user_id, n, rpu.current_win_streak)
if r: results.append(r)
elif streak_type == False:
if rpu.current_lose_streak > 0:
for n in ["upgrade_consecutive_losses_5", "upgrade_consecutive_losses_10"]:
r = check_achievement(db, user_id, n, streak)
r = check_achievement(db, user_id, n, rpu.current_lose_streak)
if r: results.append(r)
# Low value win
@@ -2287,108 +2263,115 @@ def check_inventory_achievements(db: Session, user_id: int):
return results
# Кэш ID дилдоков (строится один раз через импорт)
_DILDO_IDS_CACHE = None
def _get_dildo_ids():
global _DILDO_IDS_CACHE
if _DILDO_IDS_CACHE is None:
from backend import ALL_ITEMS
_DILDO_IDS_CACHE = [i for i, it in enumerate(ALL_ITEMS) if it.get("weapon") == "Dildo"]
return _DILDO_IDS_CACHE
def check_dildo_achievements(db: Session, user_id: int):
"""Проверяет все Dildo-связанные достижения"""
"""Проверяет все Dildo-связанные достижения — инвентарь грузит только IN-запросом."""
from database import InventoryItem
from backend import ALL_ITEMS
from sqlalchemy import func as sa_func
results = []
items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all()
dildo_items = []
for inv_item in items:
if inv_item.item_id < len(ALL_ITEMS):
item_data = ALL_ITEMS[inv_item.item_id]
if item_data.get("weapon") == "Dildo":
dildo_items.append((inv_item, item_data))
# Загружаем только dildo-предметы через IN-запрос
dildo_ids = _get_dildo_ids()
if not dildo_ids:
dildo_inv = []
else:
dildo_inv_raw = db.query(InventoryItem).filter(
InventoryItem.user_id == user_id,
InventoryItem.item_id.in_(dildo_ids)
).all()
dildo_inv = [(inv, ALL_ITEMS[inv.item_id]) for inv in dildo_inv_raw
if inv.item_id < len(ALL_ITEMS)]
dildo_count = len(dildo_items)
dildo_count = len(dildo_inv)
# Dildo count achievements
for n in ["dildo_owned", "dildo_5", "dildo_10", "dildo_25", "dildo_50", "dildo_100"]:
r = check_achievement(db, user_id, n, dildo_count)
if r: results.append(r)
# Best item price in inventory
best_price = 0
for inv_item in items:
if inv_item.item_id < len(ALL_ITEMS):
p = ALL_ITEMS[inv_item.item_id].get("price_rub", 0)
if p > best_price:
best_price = p
# Best price, total value — через aggregates
best_price = db.query(sa_func.max(InventoryItem.price_rub)).filter(
InventoryItem.user_id == user_id
).scalar() or 0
for n in ["item_worth_1m", "item_worth_10m", "item_worth_100m", "item_worth_1b"]:
r = check_achievement(db, user_id, n, best_price)
if r: results.append(r)
# Total inventory value
total_val = 0
for inv_item in items:
if inv_item.item_id < len(ALL_ITEMS):
total_val += ALL_ITEMS[inv_item.item_id].get("price_rub", 0)
total_val = db.query(sa_func.sum(InventoryItem.price_rub)).filter(
InventoryItem.user_id == user_id
).scalar() or 0
for n in ["inventory_value_10m", "inventory_value_100m", "inventory_value_1b"]:
r = check_achievement(db, user_id, n, total_val)
if r: results.append(r)
# Dildo float achievements
best_float = 1.0
for inv_item, item_data in dildo_items:
f = inv_item.float_value or 1.0
if f < best_float:
best_float = f
if best_float < 0.01:
# Float achievements
best_dildo_float = db.query(sa_func.min(InventoryItem.float_value)).filter(
InventoryItem.user_id == user_id,
InventoryItem.item_id < len(ALL_ITEMS)
).filter(
InventoryItem.item_id.in_([i for i, d in enumerate(ALL_ITEMS) if d.get("weapon") == "Dildo"])
).scalar() or 1.0
if best_dildo_float < 0.01:
r = check_achievement(db, user_id, "dildo_float_001", 1)
if r: results.append(r)
if best_float < 0.005:
if best_dildo_float < 0.005:
r = check_achievement(db, user_id, "dildo_float_0005", 2)
if r: results.append(r)
# Ultra rare float (any item)
ultra_rare = 1.0
for inv_item in items:
f = inv_item.float_value or 1.0
if f < ultra_rare:
ultra_rare = f
ultra_rare = db.query(sa_func.min(InventoryItem.float_value)).filter(
InventoryItem.user_id == user_id
).scalar() or 1.0
if ultra_rare < 0.001:
r = check_achievement(db, user_id, "ultra_rare_float", 0.001)
if r: results.append(r)
# Secret: exact float 0.001 (or close)
for inv_item in items:
f = inv_item.float_value or 0
if abs(f - 0.001) < 0.0001:
r = check_achievement(db, user_id, "secret_exact_float", 1)
if r: results.append(r)
break
# Dildo all rarities
dildo_rarities = set()
for inv_item, item_data in dildo_items:
dildo_rarities.add(item_data.get("rarity"))
if len(dildo_rarities) >= 5: # Consumer, Industrial, Mil-Spec, Restricted, Classified, Covert
r = check_achievement(db, user_id, "dildo_all_rarities", 1)
# Secret float
secret_float = db.query(InventoryItem).filter(
InventoryItem.user_id == user_id,
sa_func.abs(InventoryItem.float_value - 0.001) < 0.0001
).first()
if secret_float:
r = check_achievement(db, user_id, "secret_exact_float", 1)
if r: results.append(r)
# Dildo unique count (by pattern/name)
unique_patterns = set()
for inv_item, item_data in dildo_items:
name = item_data.get("market_hash_name", "")
unique_patterns.add(name)
r = check_achievement(db, user_id, "dildo_full_set", len(unique_patterns))
if r: results.append(r)
# Dildo rarities & unique patterns & total value — из dildo_inv (уже загружено)
if dildo_inv:
dildo_rarities = set()
unique_patterns = set()
dildo_total = 0
for inv_item, item_data in dildo_inv:
dildo_rarities.add(item_data.get("rarity"))
unique_patterns.add(item_data.get("market_hash_name", ""))
dildo_total += item_data.get("price_rub", 0)
# Dildo total value
dildo_total = 0
for inv_item, item_data in dildo_items:
dildo_total += item_data.get("price_rub", 0)
r = check_achievement(db, user_id, "dildo_worth_100m", dildo_total)
if r: results.append(r)
# Secret: specific dildo name
for inv_item, item_data in dildo_items:
name = item_data.get("market_hash_name", "")
if "Big Black" in name:
r = check_achievement(db, user_id, "secret_dildo_specific", 1)
if len(dildo_rarities) >= 5:
r = check_achievement(db, user_id, "dildo_all_rarities", 1)
if r: results.append(r)
break
r = check_achievement(db, user_id, "dildo_full_set", len(unique_patterns))
if r: results.append(r)
r = check_achievement(db, user_id, "dildo_worth_100m", dildo_total)
if r: results.append(r)
# Secret dildo name
for inv_item, item_data in dildo_inv:
if "Big Black" in item_data.get("market_hash_name", ""):
r = check_achievement(db, user_id, "secret_dildo_specific", 1)
if r: results.append(r)
break
return results
@@ -2480,14 +2463,10 @@ def check_achievements_count_achievements(db: Session, user_id: int):
def check_rare_item_count_achievements(db: Session, user_id: int):
from database import InventoryItem
from backend import ALL_ITEMS
items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all()
rare_count = 0
for inv in items:
if inv.item_id < len(ALL_ITEMS):
rar = ALL_ITEMS[inv.item_id].get("rarity", "")
if rar in ("Covert", "Extraordinary", "Rare Special Item"):
rare_count += 1
rare_count = db.query(InventoryItem).filter(
InventoryItem.user_id == user_id,
InventoryItem.rarity.in_(["Covert", "Extraordinary", "Rare Special Item"])
).count()
results = []
for n in ["rare_item_10", "rare_item_50"]:
r = check_achievement(db, user_id, n, rare_count)
@@ -2498,11 +2477,11 @@ def check_rare_item_count_achievements(db: Session, user_id: int):
def check_knife_count_achievements(db: Session, user_id: int):
from database import InventoryItem
from backend import ALL_ITEMS, is_knife, is_gloves
items = db.query(InventoryItem).filter(InventoryItem.user_id == user_id).all()
knife_count = 0
for inv in items:
if inv.item_id < len(ALL_ITEMS):
item_data = ALL_ITEMS[inv.item_id]
items = db.query(InventoryItem.item_id).filter(InventoryItem.user_id == user_id).all()
for (item_id,) in items:
if item_id < len(ALL_ITEMS):
item_data = ALL_ITEMS[item_id]
if is_knife(item_data) or is_gloves(item_data):
knife_count += 1
results = []
+411 -60
View File
@@ -8,7 +8,7 @@ import json
from datetime import datetime
from pathlib import Path
from database import get_db, User, InventoryItem, CaseOpening, Contract
from database import get_db, User, InventoryItem, CaseOpening, Contract, Upgrade, TransactionLog
from auth import get_current_admin
from rpu import get_user_rpu, calculate_rpu_level, get_rpu_description
@@ -184,6 +184,48 @@ async def admin_user_detail(
"inventory": inventory
})
@admin_router.get("/user/{user_id}/history", response_class=HTMLResponse)
async def admin_user_history(
user_id: int,
request: Request,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(404, "Пользователь не найден")
# Все открытия кейсов
openings = db.query(CaseOpening).filter(
CaseOpening.user_id == user_id
).order_by(desc(CaseOpening.opened_at)).limit(200).all()
# Все апгрейды
upgrades = db.query(Upgrade).filter(
Upgrade.user_id == user_id
).order_by(desc(Upgrade.created_at)).limit(200).all()
# Все транзакции
transactions = db.query(TransactionLog).filter(
TransactionLog.user_id == user_id
).order_by(desc(TransactionLog.created_at)).limit(200).all()
from rpu import calculate_ceiling, get_user_total_value, get_user_rpu
ceiling = calculate_ceiling(db, user_id)
total_value = get_user_total_value(db, user_id)
rpu = get_user_rpu(db, user_id)
return templates.TemplateResponse(request, "admin/user_history.html", {
"user": admin,
"target_user": target_user,
"openings": openings,
"upgrades": upgrades,
"transactions": transactions,
"ceiling": ceiling,
"total_value": total_value,
"rpu": rpu,
})
@admin_router.get("/cases", response_class=HTMLResponse)
async def admin_cases(
request: Request,
@@ -197,6 +239,7 @@ async def admin_cases(
for case in cases:
if not isinstance(case.get('items'), list):
case['items'] = []
case['items_count'] = len(case['items'])
all_items = []
for i, item in enumerate(ALL_ITEMS[:500]):
@@ -272,7 +315,7 @@ async def admin_give_item(
if not item_data:
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
item_float = generate_item_float(item_data.get("rarity", "Unknown"))
item_float = item_data.get("mid_float_used") or generate_item_float(item_data.get("rarity", "Unknown"))
inventory_item = InventoryItem(
user_id=user_id,
@@ -282,6 +325,8 @@ async def admin_give_item(
wear=item_data.get("wear", "Unknown"),
float_value=item_float,
type=item_data.get("type", "Normal"),
image_url=item_data.get("image_url", ""),
price_rub=item_data.get("price_rub", 0),
obtained_from="admin"
)
db.add(inventory_item)
@@ -370,6 +415,13 @@ async def admin_get_user_rpu(
rpu = get_user_rpu(db, user_id)
from promo_phase import get_promo_phase_info
promo = get_promo_phase_info()
# Потолок
from rpu import calculate_ceiling
ceiling = calculate_ceiling(db, user_id)
return {
"user_id": user_id,
"username": target_user.username,
@@ -387,7 +439,44 @@ async def admin_get_user_rpu(
"rpu_level": calculate_rpu_level(rpu),
"stats": {
"total_spent": rpu.total_spent,
"total_opened": rpu.total_opened
"total_opened": rpu.total_opened,
"total_value_received": rpu.total_value_received,
"current_streak": rpu.current_streak,
"best_streak": rpu.best_streak,
"worst_streak": rpu.worst_streak
},
# ── RPU v2 ──
"v2": {
"ceiling": {
"total_deposited": target_user.total_deposited or 0,
"ceiling_multiplier": rpu.ceiling_multiplier,
"current_ceiling": ceiling,
"break_count": rpu.ceiling_break_count,
"break_session": rpu.ceiling_break_session,
},
"session": {
"luck_budget": round(rpu.luck_budget, 1),
"session_spent": round(rpu.session_spent, 0),
"session_won": round(rpu.session_won, 0),
"reset_date": rpu.session_reset_date.isoformat() if rpu.session_reset_date else None,
},
"comeback": {
"active": rpu.comeback_active,
"openings_left": rpu.comeback_openings_left,
"multiplier": rpu.comeback_multiplier,
"consecutive_loss_value": round(rpu.consecutive_loss_value or 0, 0),
"consecutive_loss_count": rpu.consecutive_loss_count or 0,
},
"hot_cold": {
"hot_score": round(rpu.hot_score or 0, 2),
"last_activity": rpu.last_activity_date.isoformat() if rpu.last_activity_date else None,
},
"promo_phase": {
"phase": promo["phase"],
"effect": promo["effect"],
"next_switch": promo["next_switch"],
"remaining_minutes": promo["remaining_minutes"],
}
}
}
@@ -481,30 +570,30 @@ async def admin_set_rpu_preset(
@admin_router.post("/api/cases/create")
async def admin_create_case(
name: str = Form(...),
case_name: str = Form(...),
display_name: str = Form(...),
price: float = Form(...),
price_open: float = Form(...),
description: str = Form(""),
image_url: str = Form(""),
items: str = Form(...),
items_json: str = Form(...),
admin: User = Depends(get_current_admin)
):
cases = load_cases_config()
for case in cases:
if case['name'] == name:
if case['name'] == case_name:
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
try:
item_ids = json.loads(items)
parsed_items = json.loads(items_json)
except:
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
new_case = {
"name": name,
"name": case_name,
"display_name": display_name,
"price_open": price,
"items": item_ids,
"price_open": price_open,
"items": parsed_items,
"image_url": image_url,
"description": description
}
@@ -522,10 +611,11 @@ async def admin_create_case(
async def admin_update_case(
case_name: str,
display_name: str = Form(...),
price: float = Form(...),
price_open: float = Form(...),
description: str = Form(""),
image_url: str = Form(""),
items: str = Form(...),
items_json: str = Form(...),
new_name: str = Form(""),
admin: User = Depends(get_current_admin)
):
cases = load_cases_config()
@@ -533,18 +623,34 @@ async def admin_update_case(
for case in cases:
if case['name'] == case_name:
try:
item_ids = json.loads(items)
parsed_items = json.loads(items_json)
except:
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
target_name = new_name or case_name
# Проверяем конфликт имени при переименовании
if new_name and new_name != case_name:
for c in cases:
if c['name'] == new_name:
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
case['name'] = new_name
case['display_name'] = display_name
case['price_open'] = price
case['price_open'] = price_open
case['description'] = description
case['image_url'] = image_url
case['items'] = item_ids
case['items'] = parsed_items
save_cases_config(cases)
# Обновляем mainpage если переименован
if new_name and new_name != case_name:
mainpage = load_mainpage_config()
for section in mainpage:
section['cases'] = [new_name if c == case_name else c for c in section['cases']]
save_mainpage_config(mainpage)
return JSONResponse({
"success": True,
"case": case,
@@ -594,58 +700,91 @@ async def admin_search_items(
q: str = "",
min_price: float = 0,
max_price: float = 0,
limit: int = 200
limit: int = 200,
rarity_filter: str = "",
collection_filter: str = "",
type_filter: str = "",
weapon_filter: str = "",
sort_by: str = "",
sort_order: str = "asc",
include_subvariants: bool = False
):
"""Поиск предметов для добавления в кейс / апгрейда"""
"""Поиск предметов для добавления в кейс / апгрейда."""
from frontend import ALL_ITEMS
from backend import COLLECTIONS_INDEX
query = q.lower().strip()
results = []
seen_bases = set()
def accept_item(item, idx):
if not include_subvariants and item.get("is_subvariant"):
return False
if min_price > 0 and item.get("price_rub", 0) < min_price:
return False
if max_price > 0 and item.get("price_rub", 0) > max_price:
return False
if rarity_filter:
item_rarity = item.get("rarity", "").lower()
if rarity_filter.lower() not in item_rarity:
return False
if collection_filter:
item_coll = item.get("collection", "").lower()
if collection_filter.lower() not in item_coll:
return False
if type_filter:
item_type = item.get("type", "").lower()
if type_filter.lower() != item_type:
return False
if weapon_filter:
item_weapon = item.get("weapon", "").lower()
if weapon_filter.lower() != item_weapon:
return False
return True
def make_result(item, idx):
name = item.get("market_hash_name", "Unknown")
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
if dedup_key in seen_bases:
return None
seen_bases.add(dedup_key)
return {
"id": item.get("_id", idx),
"name": name,
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", ""),
"collection": item.get("collection", ""),
"type": item.get("type", "Normal"),
}
if query and len(query) < 2:
return []
if not query or len(query) < 2:
# Без поиска возвращаем разнообразные предметы со всего ассортимента
# Если есть ценовой фильтр — проходим по всем, иначе шагами
if min_price > 0 or max_price > 0:
if any([min_price > 0, max_price > 0, rarity_filter, collection_filter,
type_filter, weapon_filter]):
for idx, item in enumerate(ALL_ITEMS):
if accept_item(item, idx):
item_id = item.get("_id", idx)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
})
if not accept_item(item, idx):
continue
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
else:
step = max(1, len(ALL_ITEMS) // limit)
for idx in range(0, len(ALL_ITEMS), step):
if not accept_item(ALL_ITEMS[idx], idx):
continue
item = ALL_ITEMS[idx]
item_id = item.get("_id", idx)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
})
if len(results) >= 200:
break
if not accept_item(item, idx):
continue
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
else:
for idx, item in enumerate(ALL_ITEMS):
if not accept_item(item, idx):
@@ -653,24 +792,113 @@ async def admin_search_items(
name = item.get("market_hash_name", "").lower()
weapon = item.get("weapon", "").lower()
pattern = item.get("pattern", "").lower()
coll = item.get("collection", "").lower()
if query in name or query in weapon or query in pattern:
item_id = item.get("_id", idx)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
})
if len(results) >= 200:
break
if query in name or query in weapon or query in pattern or query in coll:
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
# Сортировка
if sort_by in ("price_rub", "name", "rarity"):
reverse = sort_order == "desc"
if sort_by == "rarity":
rarity_rank = {
"Consumer Grade": 0, "Industrial Grade": 1,
"Mil-Spec": 2, "Mil-Spec Grade": 2,
"Restricted": 3, "Classified": 4,
"Covert": 5, "Rare Special Item": 6,
"Extraordinary": 6, "Contraband": 7
}
results.sort(key=lambda x: rarity_rank.get(x.get("rarity", ""), 0),
reverse=reverse)
else:
results.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
return results[:200]
@admin_router.get("/api/items/filters")
async def admin_items_filters():
"""Возвращает списки уникальных значений для фильтров"""
from frontend import ALL_ITEMS
weapons = set()
types = set()
for item in ALL_ITEMS:
if item.get("is_subvariant"):
continue
w = item.get("weapon", "")
if w:
weapons.add(w)
t = item.get("type", "")
if t:
types.add(t)
return {
"weapons": sorted(weapons),
"types": sorted(types),
}
@admin_router.get("/api/collections")
async def admin_list_collections():
"""Возвращает список всех коллекций для фильтра"""
from backend import COLLECTIONS_INDEX
return {"collections": sorted(COLLECTIONS_INDEX.keys())}
@admin_router.get("/api/items/by-collection")
async def admin_items_by_collection(
collection: str = "",
rarity_filter: str = "",
type_filter: str = "",
weapon_filter: str = "",
include_subvariants: bool = False
):
"""Возвращает предметы из указанной коллекции"""
from backend import COLLECTIONS_INDEX
from frontend import get_item
results = []
seen_bases = set()
if collection not in COLLECTIONS_INDEX:
return results
for idx in COLLECTIONS_INDEX[collection]:
item = get_item(idx)
if not item:
continue
if not include_subvariants and item.get("is_subvariant"):
continue
if rarity_filter:
if rarity_filter.lower() not in item.get("rarity", "").lower():
continue
if type_filter:
if type_filter.lower() != item.get("type", "").lower():
continue
if weapon_filter:
if weapon_filter.lower() != item.get("weapon", "").lower():
continue
name = item.get("market_hash_name", "Unknown")
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
if dedup_key in seen_bases:
continue
seen_bases.add(dedup_key)
results.append({
"id": item.get("_id", idx),
"name": name,
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", ""),
"collection": item.get("collection", ""),
"type": item.get("type", "Normal"),
})
return results
@admin_router.post("/api/mainpage/section/add")
async def admin_add_mainpage_section(
tab: str = Form(...),
@@ -787,6 +1015,129 @@ async def admin_get_item(
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
# ========== API ПРОМОКОДЫ ==========
@admin_router.get("/api/promocodes")
async def admin_list_promocodes(
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
codes = db.query(PromoCode).order_by(desc(PromoCode.created_at)).limit(100).all()
return [{
"id": c.id,
"code": c.code,
"reward_type": c.reward_type,
"reward_amount": c.reward_amount,
"max_uses": c.max_uses,
"used_count": c.used_count,
"start_date": c.start_date.isoformat() if c.start_date else None,
"end_date": c.end_date.isoformat() if c.end_date else None,
"is_active": c.is_active,
} for c in codes]
@admin_router.post("/api/promocodes/create")
async def admin_create_promocode(
code: str = Form(...),
reward_type: str = Form(...),
reward_amount: float = Form(0),
reward_data: str = Form(""),
max_uses: int = Form(1),
end_date_str: str = Form(""),
is_active: bool = Form(True),
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
existing = db.query(PromoCode).filter(PromoCode.code == code).first()
if existing:
return JSONResponse(status_code=400, content={"error": "Промокод с таким кодом уже существует"})
end_date = None
if end_date_str:
try:
end_date = datetime.fromisoformat(end_date_str)
except:
pass
promo = PromoCode(
code=code,
reward_type=reward_type,
reward_amount=reward_amount,
reward_data=reward_data,
max_uses=max_uses,
end_date=end_date,
is_active=is_active,
created_by=admin.id,
)
db.add(promo)
db.commit()
return {"success": True, "message": f"Промокод '{code}' создан"}
@admin_router.post("/api/promocodes/toggle/{promo_id}")
async def admin_toggle_promocode(
promo_id: int,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first()
if not promo:
return JSONResponse(status_code=404, content={"error": "Промокод не найден"})
promo.is_active = not promo.is_active
db.commit()
return {"success": True, "is_active": promo.is_active}
@admin_router.post("/api/promocodes/delete/{promo_id}")
async def admin_delete_promocode(
promo_id: int,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first()
if not promo:
return JSONResponse(status_code=404, content={"error": "Промокод не найден"})
db.delete(promo)
db.commit()
return {"success": True, "message": "Промокод удален"}
@admin_router.get("/api/promo-phase")
async def admin_get_promo_phase(
admin: User = Depends(get_current_admin)
):
"""Информация о текущей промо-фазе (для админки)"""
from promo_phase import get_phase_debug_info
return get_phase_debug_info()
# ========== API ТРАНЗАКЦИИ ==========
@admin_router.get("/api/transactions")
async def admin_list_transactions(
user_id: Optional[int] = None,
limit: int = 100,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
query = db.query(TransactionLog)
if user_id:
query = query.filter(TransactionLog.user_id == user_id)
txns = query.order_by(desc(TransactionLog.created_at)).limit(limit).all()
return [{
"id": t.id,
"user_id": t.user_id,
"tx_type": t.tx_type,
"amount": t.amount,
"fee": t.fee,
"promo_code": t.promo_code,
"item_name": t.item_name,
"details": t.details,
"created_at": t.created_at.isoformat(),
} for t in txns]
@admin_router.post("/api/reset-password/{user_id}")
async def admin_reset_password(
user_id: int,
+107
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+201 -3
View File
@@ -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:
+684 -478
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -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"
]
},
{
+5
View File
@@ -0,0 +1,5 @@
{
"phase": "LUCKY",
"until": "2026-07-22T19:34:16.536290",
"started_at": "2026-07-22T18:22:18.082890"
}
+178
View File
@@ -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, {})
}
+352
View File
@@ -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"]
}
+558 -66
View File
@@ -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.31.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))
# Если пользователь часто открывает кейсы - немного снижаем РПУ
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)
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
View File
@@ -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
View File
@@ -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());
+77 -10
View File
@@ -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,8 +283,32 @@ function createActivityItem(act) {
const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, '');
const rarityColor = ACTIVITY_RARITY_COLORS[raritySlug] || '';
if (rarityColor) {
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
// ── Эффект ──
const effect = act.data && act.data.effect;
let effectBadgeHtml = '';
if (effect) {
const isUpgrade = effect.startsWith('upgrade:');
const isDrop = effect.startsWith('drop:');
const label = effect.replace('upgrade:', '').replace('drop:', '');
const cls = isUpgrade ? 'upgrade' : 'drop';
effectBadgeHtml = `<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}
`;
+1 -4
View File
@@ -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(() => {
-214
View File
@@ -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>
+152 -10
View File
@@ -26,6 +26,8 @@
flex-direction: column;
position: sticky; top: 0; height: 100vh;
flex-shrink: 0;
z-index: 50;
transition: transform 0.25s ease;
}
.admin-sidebar .sb-brand {
padding: 1.25rem 1.25rem 0.75rem;
@@ -73,6 +75,28 @@
}
.admin-sidebar .sb-footer a:hover { color: #f59e0b; }
/* ─── Sidebar overlay for mobile ─── */
.sidebar-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
z-index: 49;
}
.sidebar-overlay.open { display: block; }
/* ─── Hamburger ─── */
.hamburger {
display: none;
background: none;
border: none;
color: rgba(255,255,255,0.6);
font-size: 1.3rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.hamburger:hover { color: #fff; }
/* ─── Main area ─── */
.admin-main {
flex: 1;
@@ -85,8 +109,10 @@
padding: 0.85rem 1.5rem;
background: rgba(17,18,22,0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255,255,255,0.04);
position: sticky; top: 0; z-index: 10;
gap: 0.75rem;
}
.admin-topbar .at-title {
font-size: 1rem; font-weight: 600;
@@ -96,7 +122,10 @@
font-size: 0.75rem; color: rgba(255,255,255,0.35);
font-weight: 400;
}
.admin-topbar .at-actions { display: flex; align-items: center; gap: 0.75rem; }
.admin-topbar .at-actions {
display: flex; align-items: center; gap: 0.75rem;
margin-left: auto;
}
.admin-topbar .at-actions .at-back {
color: rgba(255,255,255,0.5); text-decoration: none;
font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem;
@@ -152,6 +181,7 @@
.a-stat .as-value {
font-size: 1.8rem; font-weight: 700;
line-height: 1.1;
word-break: break-word;
}
.a-stat .as-bar {
position: absolute; bottom: 0; left: 0; height: 2px;
@@ -196,6 +226,11 @@
flex-wrap: wrap;
}
/* ─── Responsive tables: card view on mobile ─── */
.a-table-card-view {
display: none;
}
/* ─── Buttons ─── */
.a-btn {
display: inline-flex; align-items: center; gap: 0.35rem;
@@ -255,6 +290,7 @@
color: rgba(255,255,255,0.5);
margin-bottom: 0.35rem;
}
select.a-input { cursor: pointer; }
/* ─── Badge ─── */
.a-badge {
@@ -272,6 +308,7 @@
.a-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 100; display: none; align-items: center; justify-content: center;
padding: 1rem;
}
@@ -283,13 +320,16 @@
padding: 1.5rem;
max-width: 460px; width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
max-height: 90vh; overflow-y: auto;
}
.a-modal-wide { max-width: 700px; }
.a-modal-title {
font-size: 1rem; font-weight: 600; margin-bottom: 1rem;
}
.a-modal-actions {
display: flex; gap: 0.5rem; margin-top: 1rem;
justify-content: flex-end;
flex-wrap: wrap;
}
/* ─── Grid helpers ─── */
@@ -298,7 +338,14 @@
.a-gap-sm { gap: 0.5rem; }
.a-mt { margin-top: 1rem; }
.a-mb { margin-bottom: 1rem; }
.a-flex { display: flex; align-items: center; gap: 0.5rem; }
.a-flex { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
/* ─── Search in top bar ─── */
.search-form {
display: flex; gap: 0.5rem; align-items: center;
}
.search-form .a-input { width: 200px; }
.search-form .a-btn { flex-shrink: 0; }
/* ─── Empty state ─── */
.a-empty {
@@ -307,20 +354,86 @@
}
.a-empty .a-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
@media(max-width:768px){
.admin-sidebar { width: 56px; }
.admin-sidebar .sb-brand span,
.admin-sidebar .sb-nav a span:not(.sb-icon),
.admin-sidebar .sb-footer span { display: none; }
.admin-sidebar .sb-nav a { justify-content: center; padding: 0.65rem; }
.admin-sidebar .sb-footer a { justify-content: center; }
/* ─── Responsive ─── */
@media(max-width: 1024px){
.admin-sidebar { width: 200px; }
}
@media(max-width: 768px){
.admin-sidebar {
position: fixed; left: 0; top: 0;
transform: translateX(-100%);
width: 260px;
box-shadow: 4px 0 30px rgba(0,0,0,0.4);
}
.admin-sidebar.open { transform: translateX(0); }
.admin-content { padding: 1rem; }
.admin-topbar { padding: 0.7rem 1rem; }
.admin-topbar .at-title .at-sub { display: none; }
.hamburger { display: inline-flex; }
.a-stats {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.a-stat { padding: 1rem; }
.a-stat .as-value { font-size: 1.4rem; }
.search-form .a-input { width: 140px; }
.a-modal { margin: 0.5rem; padding: 1rem; border-radius: 12px; }
/* Card-style table on mobile */
.a-table { display: none; }
.a-table-card-view { display: flex; flex-direction: column; gap: 0.75rem; }
.a-table-card {
background: rgba(17,18,22,0.6);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
padding: 0.85rem;
font-size: 0.82rem;
}
.a-table-card .tc-row {
display: flex; justify-content: space-between;
padding: 0.25rem 0;
gap: 0.5rem;
}
.a-table-card .tc-row .tc-label {
color: rgba(255,255,255,0.35);
font-size: 0.72rem;
flex-shrink: 0;
}
.a-table-card .tc-row .tc-value {
text-align: right;
word-break: break-word;
}
.a-table-card .tc-actions {
margin-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid rgba(255,255,255,0.04);
display: flex; gap: 0.35rem; flex-wrap: wrap;
}
}
@media(max-width: 480px){
html { font-size: 14px; }
.admin-content { padding: 0.75rem; }
.admin-topbar { padding: 0.6rem 0.75rem; }
.a-stats { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.a-stat { padding: 0.75rem; }
.a-stat .as-value { font-size: 1.2rem; }
.a-card { padding: 0.85rem; }
.search-form { width: 100%; }
.search-form .a-input { width: 100%; }
}
{% block extra_styles %}{% endblock %}
</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>
+265 -98
View File
@@ -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,57 +86,90 @@
{% 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 class="a-row a-gap-sm">
<div class="a-mb" style="flex:1">
<label class="a-label">Название кейса (ID)</label>
<input type="text" id="caseNameInput" class="a-input" placeholder="my_case">
<div 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>
<input type="text" id="caseNameInput" class="a-input" placeholder="my_case">
</div>
<div class="a-mb" style="flex:1">
<label class="a-label">Отображаемое название</label>
<input type="text" id="caseDisplayName" class="a-input" placeholder="Мой кейс">
</div>
</div>
<div class="a-mb" style="flex:1">
<label class="a-label">Отображаемое название</label>
<input type="text" id="caseDisplayName" class="a-input" placeholder="Мой кейс">
<div class="a-row a-gap-sm">
<div class="a-mb" style="flex:1">
<label class="a-label">Цена открытия</label>
<input type="number" id="casePrice" class="a-input" value="250">
</div>
<div class="a-mb" style="flex:1">
<label class="a-label">Ссылка на изображение</label>
<input type="text" id="caseImage" class="a-input" placeholder="/static/cases/default.png">
</div>
</div>
<div class="a-mb">
<label class="a-label">Описание (необязательно)</label>
<input type="text" id="caseDescription" class="a-input" placeholder="Описание кейса...">
</div>
<!-- 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-row a-gap-sm">
<div class="a-mb" style="flex:1">
<label class="a-label">Цена открытия</label>
<input type="number" id="casePrice" class="a-input" value="250">
</div>
<div class="a-mb" style="flex:1">
<label class="a-label">Ссылка на изображение</label>
<input type="text" id="caseImage" class="a-input" placeholder="/static/cases/default.png">
</div>
</div>
<div class="a-mb">
<label class="a-label">Описание (необязательно)</label>
<input type="text" id="caseDescription" class="a-input" placeholder="Описание кейса...">
</div>
<div class="a-card" style="background:rgba(0,0,0,0.2);padding:0.75rem">
<div class="a-card-header" style="margin-bottom:0.5rem">📋 Предметы кейса</div>
<div id="caseItemsList"></div>
<div class="a-flex a-mt" style="flex-wrap:wrap">
<input type="text" id="caseItemSearch" class="a-input a-input-sm" style="flex:1;min-width:120px" placeholder="Поиск предмета..." oninput="searchCaseItems()">
<button class="a-btn a-btn-sm" onclick="openAllItemsModal()">📦 Все предметы</button>
</div>
</div>
<div class="a-modal-actions">
<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,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ci-info"><div>${item.name}</div><div class="ci-rarity">${item.rarity} • ${item.price_rub||0} ₽</div></div>
</div>`;
});
html += '</div>';
const existing = document.getElementById('caseItemSearchResults');
if (!existing) {
const d = document.createElement('div'); d.id = 'caseItemSearchResults';
document.getElementById('caseItemSearch').parentNode.appendChild(d);
}
document.getElementById('caseItemSearchResults').innerHTML = html;
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');
}
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,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="ci-info">
<div>${item.name}</div>
<div class="ci-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,'&quot;')})">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div style="margin-top:0.2rem;line-height:1.15">${item.name}</div>
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35)">${item.rarity}</div>
</div>
`).join('');
}
function addCaseItemFromAll(item) {
addCaseItem(item);
closeModal('allItemsModal');
}
// ─── Mainpage Sections ───────────────────────────────────────────────────────
function openAddSectionModal() {
document.getElementById('newSectionName').value = '';
@@ -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');
}
+27
View File
@@ -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 %}
+61 -1
View File
@@ -8,10 +8,11 @@
{% endblock %}
{% block top_actions %}
<a href="/admin/users" class="at-back">&larr; Назад</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(_) {}
}
+122
View File
@@ -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">&larr; Профиль</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 %}
+36 -2
View File
@@ -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
View File
@@ -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);
});
}
}
+147
View File
@@ -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' });
+1 -1
View File
@@ -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();
+8 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()