Compare commits
13 Commits
fe6bf5cf29
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fed81082f | |||
| 17da0b6d79 | |||
| 47c7f17c90 | |||
| 6638ab99f3 | |||
| f23a79d618 | |||
| 8110f39ef5 | |||
| 95f3dc7f20 | |||
| 2570012c59 | |||
| dc3e2f682e | |||
| 5485dce2d3 | |||
| 396d21bcf4 | |||
| 3fe8a47e04 | |||
| 50535ec777 |
+5
-1
@@ -1,4 +1,8 @@
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.bak
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
venv/
|
||||
|
||||
+758
-352
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,21 @@ 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
|
||||
|
||||
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
def money_filter(value):
|
||||
try:
|
||||
return "{:,.0f}".format(float(value)).replace(",", " ")
|
||||
except (ValueError, TypeError):
|
||||
return "0"
|
||||
|
||||
templates.env.filters["money"] = money_filter
|
||||
|
||||
# Загрузка конфигов
|
||||
CASES_FILE = Path("cases.json")
|
||||
MAINPAGE_FILE = Path("mainpage.json")
|
||||
@@ -93,8 +101,7 @@ async def admin_dashboard(
|
||||
|
||||
recent_users = db.query(User).order_by(desc(User.created_at)).limit(10).all()
|
||||
|
||||
return templates.TemplateResponse("admin/dashboard.html", {
|
||||
"request": request,
|
||||
return templates.TemplateResponse(request, "admin/dashboard.html", {
|
||||
"user": admin,
|
||||
"total_users": total_users,
|
||||
"total_items": total_items,
|
||||
@@ -137,13 +144,13 @@ async def admin_users(
|
||||
"balance": u.balance,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at,
|
||||
"inventory_items": u.inventory_items,
|
||||
"case_openings": u.case_openings,
|
||||
"case_openings": db.query(CaseOpening).filter(CaseOpening.user_id == u.id).count(),
|
||||
"contracts": db.query(Contract).filter(Contract.user_id == u.id).count(),
|
||||
"inventory_count": db.query(InventoryItem).filter(InventoryItem.user_id == u.id).count(),
|
||||
"rpu_level": round(calculate_rpu_level(rpu))
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("admin/users.html", {
|
||||
"request": request,
|
||||
return templates.TemplateResponse(request, "admin/users.html", {
|
||||
"user": admin,
|
||||
"users": users_with_rpu,
|
||||
"search": search
|
||||
@@ -168,8 +175,7 @@ async def admin_user_detail(
|
||||
InventoryItem.user_id == user_id
|
||||
).order_by(desc(InventoryItem.obtained_at)).limit(50).all()
|
||||
|
||||
return templates.TemplateResponse("admin/user_detail.html", {
|
||||
"request": request,
|
||||
return templates.TemplateResponse(request, "admin/user_detail.html", {
|
||||
"user": admin,
|
||||
"target_user": target_user,
|
||||
"total_items": total_items,
|
||||
@@ -178,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,
|
||||
@@ -191,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]):
|
||||
@@ -201,8 +250,7 @@ async def admin_cases(
|
||||
"image_url": item.get("image_url", "")
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("admin/cases.html", {
|
||||
"request": request,
|
||||
return templates.TemplateResponse(request, "admin/cases.html", {
|
||||
"user": admin,
|
||||
"cases": cases,
|
||||
"mainpage": mainpage,
|
||||
@@ -267,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,
|
||||
@@ -277,11 +325,16 @@ 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)
|
||||
db.commit()
|
||||
|
||||
from achievements import check_collection_achievements
|
||||
check_collection_achievements(db, user_id)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Предмет '{item_data.get('market_hash_name')}' выдан пользователю {target_user.username}"
|
||||
@@ -362,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,
|
||||
@@ -379,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"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,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
|
||||
}
|
||||
@@ -514,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()
|
||||
@@ -525,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,
|
||||
@@ -586,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):
|
||||
@@ -645,24 +792,113 @@ async def admin_search_items(
|
||||
name = item.get("market_hash_name", "").lower()
|
||||
weapon = item.get("weapon", "").lower()
|
||||
pattern = item.get("pattern", "").lower()
|
||||
|
||||
if query in name or query in weapon or query in pattern:
|
||||
item_id = item.get("_id", idx)
|
||||
results.append({
|
||||
"id": item_id,
|
||||
"name": item.get("market_hash_name", "Unknown"),
|
||||
"rarity": item.get("rarity", "Unknown"),
|
||||
"image_url": item.get("image_url", ""),
|
||||
"price_rub": item.get("price_rub", 0),
|
||||
"weapon": item.get("weapon", ""),
|
||||
"pattern": item.get("pattern", "")
|
||||
})
|
||||
if len(results) >= 200:
|
||||
break
|
||||
|
||||
coll = item.get("collection", "").lower()
|
||||
|
||||
if query in name or query in weapon or query in pattern or query in coll:
|
||||
r = make_result(item, idx)
|
||||
if r:
|
||||
results.append(r)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
# Сортировка
|
||||
if sort_by in ("price_rub", "name", "rarity"):
|
||||
reverse = sort_order == "desc"
|
||||
if sort_by == "rarity":
|
||||
rarity_rank = {
|
||||
"Consumer Grade": 0, "Industrial Grade": 1,
|
||||
"Mil-Spec": 2, "Mil-Spec Grade": 2,
|
||||
"Restricted": 3, "Classified": 4,
|
||||
"Covert": 5, "Rare Special Item": 6,
|
||||
"Extraordinary": 6, "Contraband": 7
|
||||
}
|
||||
results.sort(key=lambda x: rarity_rank.get(x.get("rarity", ""), 0),
|
||||
reverse=reverse)
|
||||
else:
|
||||
results.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
|
||||
|
||||
return results[:200]
|
||||
|
||||
|
||||
@admin_router.get("/api/items/filters")
|
||||
async def admin_items_filters():
|
||||
"""Возвращает списки уникальных значений для фильтров"""
|
||||
from frontend import ALL_ITEMS
|
||||
|
||||
weapons = set()
|
||||
types = set()
|
||||
for item in ALL_ITEMS:
|
||||
if item.get("is_subvariant"):
|
||||
continue
|
||||
w = item.get("weapon", "")
|
||||
if w:
|
||||
weapons.add(w)
|
||||
t = item.get("type", "")
|
||||
if t:
|
||||
types.add(t)
|
||||
|
||||
return {
|
||||
"weapons": sorted(weapons),
|
||||
"types": sorted(types),
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/api/collections")
|
||||
async def admin_list_collections():
|
||||
"""Возвращает список всех коллекций для фильтра"""
|
||||
from backend import COLLECTIONS_INDEX
|
||||
return {"collections": sorted(COLLECTIONS_INDEX.keys())}
|
||||
|
||||
|
||||
@admin_router.get("/api/items/by-collection")
|
||||
async def admin_items_by_collection(
|
||||
collection: str = "",
|
||||
rarity_filter: str = "",
|
||||
type_filter: str = "",
|
||||
weapon_filter: str = "",
|
||||
include_subvariants: bool = False
|
||||
):
|
||||
"""Возвращает предметы из указанной коллекции"""
|
||||
from backend import COLLECTIONS_INDEX
|
||||
from frontend import get_item
|
||||
results = []
|
||||
seen_bases = set()
|
||||
if collection not in COLLECTIONS_INDEX:
|
||||
return results
|
||||
for idx in COLLECTIONS_INDEX[collection]:
|
||||
item = get_item(idx)
|
||||
if not item:
|
||||
continue
|
||||
if not include_subvariants and item.get("is_subvariant"):
|
||||
continue
|
||||
if rarity_filter:
|
||||
if rarity_filter.lower() not in item.get("rarity", "").lower():
|
||||
continue
|
||||
if type_filter:
|
||||
if type_filter.lower() != item.get("type", "").lower():
|
||||
continue
|
||||
if weapon_filter:
|
||||
if weapon_filter.lower() != item.get("weapon", "").lower():
|
||||
continue
|
||||
name = item.get("market_hash_name", "Unknown")
|
||||
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
|
||||
if dedup_key in seen_bases:
|
||||
continue
|
||||
seen_bases.add(dedup_key)
|
||||
results.append({
|
||||
"id": item.get("_id", idx),
|
||||
"name": name,
|
||||
"rarity": item.get("rarity", "Unknown"),
|
||||
"image_url": item.get("image_url", ""),
|
||||
"price_rub": item.get("price_rub", 0),
|
||||
"weapon": item.get("weapon", ""),
|
||||
"pattern": item.get("pattern", ""),
|
||||
"collection": item.get("collection", ""),
|
||||
"type": item.get("type", "Normal"),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
@admin_router.post("/api/mainpage/section/add")
|
||||
async def admin_add_mainpage_section(
|
||||
tab: str = Form(...),
|
||||
@@ -779,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,
|
||||
|
||||
@@ -3,127 +3,135 @@ from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db, User
|
||||
from database import get_async_db, get_db, User
|
||||
import os
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Декодирует JWT токен"""
|
||||
if not token: # <-- Добавлена проверка
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
async def get_current_user_from_token(token: str, db: Session) -> Optional[User]:
|
||||
"""Получает пользователя из JWT токена"""
|
||||
if not token: # <-- Добавлена проверка
|
||||
|
||||
async def get_current_user_from_token(token: str, db) -> Optional[User]:
|
||||
if not token:
|
||||
return None
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
return None
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
return user
|
||||
if isinstance(db, AsyncSession):
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
return result.scalar_one_or_none()
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
async def get_current_user_optional(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
|
||||
|
||||
async def get_current_user_optional(request: Request, db=Depends(get_async_db)) -> Optional[User]:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token: # <-- Исправлено: сразу возвращаем None
|
||||
if not token:
|
||||
return None
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:]
|
||||
return await get_current_user_from_token(token, db)
|
||||
|
||||
async def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
|
||||
async def get_current_user(request: Request, db=Depends(get_async_db)) -> User:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:]
|
||||
|
||||
user = await get_current_user_from_token(token, db)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin(request: Request, db=Depends(get_async_db)) -> User:
|
||||
user = await get_current_user(request, db)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# ─── Sync auth deps (for non-converted endpoints) ──────────────────────────
|
||||
|
||||
async def get_current_user_sync(request: Request, db=Depends(get_db)) -> User:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:]
|
||||
user = db.query(User).filter(User.username == decode_token(token).get("sub")).first() if decode_token(token) else None
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_optional_sync(request: Request, db=Depends(get_db)) -> Optional[User]:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
return None
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:]
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
return db.query(User).filter(User.username == payload.get("sub")).first()
|
||||
|
||||
|
||||
def create_user(db: Session, username: str, password: str) -> User:
|
||||
hashed_password = get_password_hash(password)
|
||||
user = User(username=username, hashed_password=hashed_password, balance=1000.0)
|
||||
hashed = get_password_hash(password)
|
||||
user = User(username=username, hashed_password=hashed, balance=1000.0)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def is_item_craftable(item_id: int) -> bool:
|
||||
"""Проверяет, можно ли использовать предмет в контракте"""
|
||||
from backend import get_item, is_final_in_collection
|
||||
|
||||
item = get_item(item_id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
if not item.get("is_craftable", True):
|
||||
return False
|
||||
|
||||
if is_final_in_collection(item):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def get_current_admin(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
"""Проверяет, что пользователь - админ"""
|
||||
user = await get_current_user(request, db)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return user
|
||||
+64
@@ -103,6 +103,66 @@ def load_custom_items() -> List[dict]:
|
||||
|
||||
return custom_items
|
||||
|
||||
# ─── Генерация sub-вариаций цен и float ───────────────────────────────────
|
||||
def generate_item_variants(num_variants: int = 6):
|
||||
"""Для каждого предмета генерирует sub-вариации с разными ценами и float.
|
||||
|
||||
Оригиналы остаются на своих местах (индексы 0..N-1 не меняются),
|
||||
sub-вариации добавляются в конец списка (индексы N..N+N*5-1),
|
||||
чтобы не ломать ссылки по индексам (cases.json, база данных).
|
||||
"""
|
||||
global ALL_ITEMS
|
||||
n_originals = len(ALL_ITEMS)
|
||||
sub_variants = []
|
||||
|
||||
for idx, item in enumerate(ALL_ITEMS):
|
||||
base_price = item.get("price_rub", 0)
|
||||
wr = item.get("wear_range", [0, 1])
|
||||
mid = item.get("mid_float_used", 0.5)
|
||||
wr_min, wr_max = wr
|
||||
f_range = max(wr_max - wr_min, 0.001)
|
||||
|
||||
# v=0 — сам оригинал (оставляем как есть)
|
||||
# v=1..num_variants-1 — sub-вариации (добавляем в конец)
|
||||
for v in range(1, num_variants):
|
||||
variant = dict(item)
|
||||
variant["is_subvariant"] = True
|
||||
t = v / (num_variants - 1) if num_variants > 1 else 0
|
||||
|
||||
# price factor: 0.55 to 1.40 across variants
|
||||
factor = 0.55 + t * 0.85
|
||||
factor += random.uniform(-0.05, 0.05)
|
||||
factor = max(0.30, min(1.50, factor))
|
||||
|
||||
p = int(base_price * factor)
|
||||
if p >= 1_000_000:
|
||||
prefix = p // 1000
|
||||
suffix = random.randint(100, 999)
|
||||
p = prefix * 1000 + suffix
|
||||
elif p >= 100_000:
|
||||
prefix = p // 100
|
||||
suffix = random.randint(10, 99)
|
||||
p = prefix * 100 + suffix
|
||||
elif p >= 10_000:
|
||||
prefix = p // 10
|
||||
suffix = random.randint(1, 9)
|
||||
p = prefix * 10 + suffix
|
||||
variant["price_rub"] = max(p, 100)
|
||||
variant["price_usd"] = round(variant["price_rub"] / 100, 2)
|
||||
|
||||
# float spreads across the wear range
|
||||
float_offset = -0.4 + t * 0.8
|
||||
float_offset += random.uniform(-0.1, 0.1)
|
||||
new_mid = mid + float_offset * f_range
|
||||
new_mid = max(wr_min + 0.001, min(wr_max - 0.001, new_mid))
|
||||
variant["mid_float_used"] = round(new_mid, 4)
|
||||
|
||||
sub_variants.append(variant)
|
||||
|
||||
ALL_ITEMS = ALL_ITEMS + sub_variants
|
||||
print(f"Сгенерировано {len(sub_variants)} sub-вариаций для {n_originals} предметов (x{num_variants})")
|
||||
print(f"Оригиналы: индексы 0..{n_originals - 1}, sub-вариации: {n_originals}..{len(ALL_ITEMS) - 1}")
|
||||
|
||||
# ─── Загрузка основных данных ─────────────────────────────────────────────
|
||||
def load_base_items() -> List[dict]:
|
||||
"""Загружает основные предметы из skins.json"""
|
||||
@@ -126,6 +186,10 @@ CUSTOM_ITEMS = load_custom_items()
|
||||
ALL_ITEMS = BASE_ITEMS + CUSTOM_ITEMS
|
||||
print(f"Всего предметов после объединения: {len(ALL_ITEMS):,}")
|
||||
|
||||
# Генерируем sub-вариации цен и float для каждого предмета
|
||||
generate_item_variants(num_variants=6)
|
||||
print(f"Всего предметов после генерации вариаций: {len(ALL_ITEMS):,}")
|
||||
|
||||
ITEM_BY_ID: Dict[int, dict] = {i: item for i, item in enumerate(ALL_ITEMS)}
|
||||
|
||||
# ─── Глобальные кэши / индексы ──────────────────────────────────────────────
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
BankAPI — внешний сервис для управления "картой" пользователя.
|
||||
Сейчас заглушка (mock), архитектура под настоящий REST.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BankAPIClient:
|
||||
"""
|
||||
Клиент для взаимодействия с Bank API.
|
||||
В будущем — HTTP-клиент к внешнему сервису.
|
||||
Сейчас — in-memory заглушка.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = "http://bank-api:8000"):
|
||||
self.base_url = base_url
|
||||
# mock: user_id -> {balance, transactions}
|
||||
self._mock_balances: dict[int, float] = {}
|
||||
self._mock_txns: dict[int, list] = {}
|
||||
self._initial_balance = 1000.0
|
||||
|
||||
def _ensure_user(self, user_id: int):
|
||||
if user_id not in self._mock_balances:
|
||||
self._mock_balances[user_id] = self._initial_balance
|
||||
self._mock_txns[user_id] = []
|
||||
|
||||
async def get_balance(self, user_id: int) -> float:
|
||||
"""Сколько денег на карте пользователя"""
|
||||
self._ensure_user(user_id)
|
||||
return self._mock_balances[user_id]
|
||||
|
||||
async def transfer_to_site(self, user_id: int, amount: float,
|
||||
promo_code: Optional[str] = None) -> dict:
|
||||
"""
|
||||
Перевод с карты на баланс сайта.
|
||||
Возвращает: {success, amount, new_card_balance, promo_effect}
|
||||
"""
|
||||
self._ensure_user(user_id)
|
||||
if self._mock_balances[user_id] < amount:
|
||||
return {"success": False, "error": "Недостаточно средств на карте"}
|
||||
|
||||
self._mock_balances[user_id] -= amount
|
||||
self._mock_txns[user_id].append({
|
||||
"type": "card_to_site",
|
||||
"amount": amount,
|
||||
"promo": promo_code,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"amount": amount,
|
||||
"new_card_balance": self._mock_balances[user_id],
|
||||
"promo_effect": None
|
||||
}
|
||||
|
||||
async def withdraw_to_card(self, user_id: int, amount: float) -> dict:
|
||||
"""
|
||||
Вывод с баланса сайта на карту.
|
||||
Возвращает: {success, amount, new_card_balance, fee}
|
||||
"""
|
||||
self._ensure_user(user_id)
|
||||
fee = round(amount * 0.10, 2) # 10% комиссия
|
||||
net = round(amount - fee, 2)
|
||||
|
||||
self._mock_balances[user_id] += net
|
||||
self._mock_txns[user_id].append({
|
||||
"type": "site_to_card",
|
||||
"gross": amount,
|
||||
"fee": fee,
|
||||
"net": net,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"amount": amount,
|
||||
"fee": fee,
|
||||
"net": net,
|
||||
"new_card_balance": self._mock_balances[user_id]
|
||||
}
|
||||
|
||||
async def validate_promo(self, user_id: int, promo_code: str) -> dict:
|
||||
"""
|
||||
Проверяет промокод. Возвращает его эффект для перевода card->site.
|
||||
"""
|
||||
# Заглушка — реальная логика будет в PromoCode сервисе
|
||||
return {
|
||||
"valid": True,
|
||||
"promo_type": "card_to_site",
|
||||
"amount": 0,
|
||||
"luck_boost": 0,
|
||||
"ceiling_boost": 0
|
||||
}
|
||||
|
||||
async def get_transaction_history(self, user_id: int,
|
||||
limit: int = 50) -> list:
|
||||
"""История транзакций по карте"""
|
||||
self._ensure_user(user_id)
|
||||
return self._mock_txns[user_id][-limit:]
|
||||
|
||||
|
||||
# Singleton
|
||||
bank_client = BankAPIClient()
|
||||
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Optional, Any, Callable, Awaitable
|
||||
from datetime import timedelta
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
CACHE_TTL = int(os.getenv("CACHE_TTL", "300"))
|
||||
|
||||
_redis_pool = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_redis():
|
||||
global _redis_pool
|
||||
if _redis_pool is None:
|
||||
import aioredis
|
||||
async with _lock:
|
||||
if _redis_pool is None:
|
||||
_redis_pool = await aioredis.from_url(REDIS_URL, decode_responses=True)
|
||||
return _redis_pool
|
||||
|
||||
|
||||
async def cache_get(key: str) -> Optional[Any]:
|
||||
try:
|
||||
r = await get_redis()
|
||||
val = await r.get(key)
|
||||
if val:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def cache_set(key: str, value: Any, ttl: int = CACHE_TTL):
|
||||
try:
|
||||
r = await get_redis()
|
||||
await r.setex(key, ttl, json.dumps(value, default=str))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def cache_delete(key: str):
|
||||
try:
|
||||
r = await get_redis()
|
||||
await r.delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def cache_get_or_set(key: str, factory: Callable[[], Awaitable[Any]], ttl: int = CACHE_TTL) -> Any:
|
||||
cached = await cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = await factory()
|
||||
await cache_set(key, value, ttl)
|
||||
return value
|
||||
|
||||
|
||||
async def cache_invalidate_pattern(pattern: str):
|
||||
try:
|
||||
r = await get_redis()
|
||||
keys = await r.keys(pattern)
|
||||
if keys:
|
||||
await r.delete(*keys)
|
||||
except Exception:
|
||||
pass
|
||||
+1899
-1
File diff suppressed because it is too large
Load Diff
+2483
-23
File diff suppressed because it is too large
Load Diff
+240
-72
@@ -1,19 +1,59 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Generator, Optional
|
||||
|
||||
DATABASE_URL = "sqlite:///./cs2_simulator.db"
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
from sqlalchemy import (
|
||||
create_engine, Column, Integer, String, Float, Boolean,
|
||||
DateTime, ForeignKey, Text, text, inspect, Index
|
||||
)
|
||||
from sqlalchemy.orm import sessionmaker, relationship, DeclarativeBase
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
|
||||
# ─── Config ──────────────────────────────────────────────────────────────────
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./cs2_simulator.db")
|
||||
ASYNC_DATABASE_URL = os.getenv("ASYNC_DATABASE_URL", "sqlite+aiosqlite:///./cs2_simulator.db")
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
USE_ASYNC = os.getenv("USE_ASYNC", "0") == "1" or "postgresql" in ASYNC_DATABASE_URL
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
# ─── Sync engine (SQLite fallback / admin) ──────────────────────────────────
|
||||
is_sqlite = DATABASE_URL.startswith("sqlite")
|
||||
sync_connect_args = {"check_same_thread": False} if is_sqlite else {}
|
||||
engine = create_engine(DATABASE_URL, connect_args=sync_connect_args, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
if is_sqlite:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
conn.execute(text("PRAGMA synchronous=NORMAL"))
|
||||
conn.execute(text("PRAGMA cache_size=-64000"))
|
||||
conn.execute(text("PRAGMA busy_timeout=5000"))
|
||||
conn.commit()
|
||||
|
||||
# ─── Async engine (PostgreSQL production) ───────────────────────────────────
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_DATABASE_URL,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)
|
||||
|
||||
# ─── Models ─────────────────────────────────────────────────────────────────
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (
|
||||
Index("ix_users_username", "username"),
|
||||
Index("ix_users_created_at", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
username = Column(String(50), unique=True, nullable=False)
|
||||
hashed_password = Column(String(200), nullable=False)
|
||||
balance = Column(Float, default=0.0)
|
||||
@@ -21,41 +61,45 @@ class User(Base):
|
||||
is_banned = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
card_balance_cached = Column(Float, default=1000.0)
|
||||
total_deposited = Column(Float, default=0.0)
|
||||
total_withdrawn = Column(Float, default=0.0)
|
||||
last_deposit_date = Column(DateTime, nullable=True)
|
||||
|
||||
inventory_items = relationship("InventoryItem", back_populates="user", cascade="all, delete-orphan")
|
||||
case_openings = relationship("CaseOpening", back_populates="user", cascade="all, delete-orphan")
|
||||
contracts = relationship("Contract", back_populates="user", cascade="all, delete-orphan")
|
||||
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"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (Index("ix_upgrade_rpu_user_id", "user_id", unique=True),)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
# Множитель шанса апгрейда (1.0 = стандартный)
|
||||
upgrade_multiplier = Column(Float, default=1.0)
|
||||
|
||||
# Автоматическая подкрутка
|
||||
auto_adjust = Column(Boolean, default=False)
|
||||
|
||||
# Статистика
|
||||
total_attempts = Column(Integer, default=0)
|
||||
total_success = Column(Integer, default=0)
|
||||
total_spent_value = Column(Float, default=0.0) # Стоимость потерянных предметов
|
||||
|
||||
total_spent_value = Column(Float, default=0.0)
|
||||
current_win_streak = Column(Integer, default=0)
|
||||
current_lose_streak = Column(Integer, default=0)
|
||||
best_win_streak = Column(Integer, default=0)
|
||||
worst_lose_streak = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="upgrade_rpu")
|
||||
|
||||
|
||||
class Upgrade(Base):
|
||||
__tablename__ = "upgrades"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (Index("ix_upgrades_user_id_created", "user_id", "created_at"),)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
input_item_id = Column(Integer, nullable=False)
|
||||
input_item_name = Column(String(200))
|
||||
@@ -64,19 +108,18 @@ class Upgrade(Base):
|
||||
target_item_name = Column(String(200))
|
||||
target_item_image_url = Column(String(500), default="")
|
||||
success = Column(Boolean, default=False)
|
||||
probability = Column(Float) # Базовый шанс
|
||||
rpu_adjusted_probability = Column(Float) # Шанс с учетом РПУ
|
||||
probability = Column(Float)
|
||||
rpu_adjusted_probability = Column(Float)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="upgrades")
|
||||
|
||||
|
||||
class UserRPU(Base):
|
||||
__tablename__ = "user_rpu"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (Index("ix_user_rpu_user_id", "user_id", unique=True),)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
# Множители шансов для каждой редкости (1.0 = стандартный шанс)
|
||||
consumer_multiplier = Column(Float, default=1.0)
|
||||
industrial_multiplier = Column(Float, default=1.0)
|
||||
mil_spec_multiplier = Column(Float, default=1.0)
|
||||
@@ -84,46 +127,106 @@ class UserRPU(Base):
|
||||
classified_multiplier = Column(Float, default=1.0)
|
||||
covert_multiplier = Column(Float, default=1.0)
|
||||
rare_special_multiplier = Column(Float, default=1.0)
|
||||
|
||||
# Общий множитель удачи
|
||||
luck_multiplier = Column(Float, default=1.0)
|
||||
|
||||
# Автоматическая подкрутка
|
||||
auto_adjust = Column(Boolean, default=False)
|
||||
|
||||
# Статистика для авто-подкрутки
|
||||
total_spent = Column(Float, default=0.0) # Всего потрачено
|
||||
total_opened = Column(Integer, default=0) # Всего открыто кейсов
|
||||
total_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="")
|
||||
luck_budget = Column(Float, default=100.0)
|
||||
session_spent = Column(Float, default=0.0)
|
||||
session_won = Column(Float, default=0.0)
|
||||
session_reset_date = Column(DateTime, nullable=True)
|
||||
ceiling_multiplier = Column(Float, default=1.0)
|
||||
ceiling_break_count = Column(Integer, default=0)
|
||||
ceiling_break_session = Column(Integer, default=0)
|
||||
consecutive_loss_value = Column(Float, default=0.0)
|
||||
consecutive_loss_count = Column(Integer, default=0)
|
||||
comeback_active = Column(Boolean, default=False)
|
||||
comeback_openings_left = Column(Integer, default=0)
|
||||
comeback_multiplier = Column(Float, default=1.0)
|
||||
hot_score = Column(Float, default=0.0)
|
||||
last_activity_date = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="rpu_settings")
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promo_codes"
|
||||
__table_args__ = (Index("ix_promo_codes_code", "code"), Index("ix_promo_codes_active", "is_active"))
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
code = Column(String(50), unique=True, nullable=False)
|
||||
reward_type = Column(String(30), nullable=False)
|
||||
reward_amount = Column(Float, default=0.0)
|
||||
reward_data = Column(String(500), default="")
|
||||
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"
|
||||
__table_args__ = (
|
||||
Index("ix_txlog_user_id", "user_id"),
|
||||
Index("ix_txlog_created", "created_at"),
|
||||
Index("ix_txlog_user_created", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
tx_type = Column(String(30), nullable=False)
|
||||
amount = Column(Float, default=0.0)
|
||||
fee = Column(Float, default=0.0)
|
||||
promo_code = Column(String(50), nullable=True)
|
||||
item_name = Column(String(200), nullable=True)
|
||||
inventory_item_id = Column(Integer, nullable=True)
|
||||
details = Column(String(500), default="")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
user = relationship("User", back_populates="transactions")
|
||||
|
||||
|
||||
class InventoryItem(Base):
|
||||
__tablename__ = "inventory_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (
|
||||
Index("ix_inventory_user_id", "user_id"),
|
||||
Index("ix_inventory_user_obtained", "user_id", "obtained_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
item_id = Column(Integer, nullable=False) # ID из ALL_ITEMS
|
||||
item_id = Column(Integer, nullable=False)
|
||||
market_hash_name = Column(String(200), nullable=False)
|
||||
rarity = Column(String(50))
|
||||
wear = Column(String(50))
|
||||
float_value = Column(Float, default=0.0)
|
||||
type = Column(String(20), default="Normal")
|
||||
obtained_from = Column(String(100)) # "case: название" или "contract"
|
||||
obtained_from = Column(String(100))
|
||||
obtained_at = Column(DateTime, default=datetime.utcnow)
|
||||
is_equipped = Column(Boolean, default=False)
|
||||
|
||||
image_url = Column(String(500), default="")
|
||||
price_rub = Column(Float, default=0.0)
|
||||
user = relationship("User", back_populates="inventory_items")
|
||||
|
||||
|
||||
class CaseOpening(Base):
|
||||
__tablename__ = "case_openings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (
|
||||
Index("ix_case_openings_user_id", "user_id"),
|
||||
Index("ix_case_openings_opened_at", "opened_at"),
|
||||
Index("ix_case_openings_user_opened", "user_id", "opened_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
case_name = Column(String(100), nullable=False)
|
||||
item_id = Column(Integer, nullable=False)
|
||||
@@ -131,73 +234,138 @@ class CaseOpening(Base):
|
||||
rarity = Column(String(50))
|
||||
float_value = Column(Float)
|
||||
opened_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="case_openings")
|
||||
|
||||
|
||||
class Contract(Base):
|
||||
__tablename__ = "contracts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (
|
||||
Index("ix_contracts_user_id", "user_id"),
|
||||
Index("ix_contracts_user_created", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
input_item_ids = Column(Text, nullable=False) # JSON список ID из инвентаря
|
||||
input_item_ids = Column(Text, nullable=False)
|
||||
output_item_id = Column(Integer, nullable=False)
|
||||
output_item_name = Column(String(200))
|
||||
output_float = Column(Float)
|
||||
probability = Column(Float)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="contracts")
|
||||
|
||||
|
||||
class Achievement(Base):
|
||||
__tablename__ = "achievements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(String(500))
|
||||
icon = Column(String(50), default="🏆")
|
||||
category = Column(String(50), default="general") # cases, contracts, upgrade, social, general
|
||||
requirement_type = Column(String(50), nullable=False) # open_cases, contracts, upgrade_wins, total_spent, valuable_item, etc.
|
||||
requirement_value = Column(Integer, nullable=False) # how many
|
||||
reward_amount = Column(Float, default=0.0) # bonus rubles
|
||||
category = Column(String(50), default="general")
|
||||
requirement_type = Column(String(50), nullable=False)
|
||||
requirement_value = Column(Integer, nullable=False)
|
||||
reward_amount = Column(Float, default=0.0)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
hidden = Column(Boolean, default=False)
|
||||
user_achievements = relationship("UserAchievement", back_populates="achievement")
|
||||
|
||||
|
||||
class UserAchievement(Base):
|
||||
__tablename__ = "user_achievements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (
|
||||
Index("ix_user_achievements_user", "user_id"),
|
||||
Index("ix_user_achievements_ach", "achievement_id"),
|
||||
Index("ix_user_achievements_both", "user_id", "achievement_id", unique=True),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False)
|
||||
progress = Column(Integer, default=0) # текущий прогресс
|
||||
progress = Column(Integer, default=0)
|
||||
unlocked_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", backref="user_achievements")
|
||||
achievement = relationship("Achievement", back_populates="user_achievements")
|
||||
|
||||
|
||||
class ActivityFeed(Base):
|
||||
__tablename__ = "activity_feed"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
__table_args__ = (
|
||||
Index("ix_activity_user_id", "user_id"),
|
||||
Index("ix_activity_created", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
username = Column(String(50), nullable=False)
|
||||
activity_type = Column(String(50), nullable=False) # case_open, contract, upgrade, achievement, trade, crash
|
||||
activity_type = Column(String(50), nullable=False)
|
||||
message = Column(String(500), nullable=False)
|
||||
data_json = Column(Text, default="{}") # дополнительные данные (item_name, price, etc.)
|
||||
data_json = Column(Text, default="{}")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", backref="activities")
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def get_db():
|
||||
# ─── Create tables ──────────────────────────────────────────────────────────
|
||||
def init_sync_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_run_migrations(engine, is_sync=True)
|
||||
|
||||
|
||||
async def init_async_db():
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
def _run_migrations(engine_or_conn, is_sync: bool):
|
||||
try:
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn)
|
||||
existing_tables = inspector.get_table_names()
|
||||
for tbl_name in Base.metadata.tables:
|
||||
if tbl_name not in existing_tables:
|
||||
continue
|
||||
model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns}
|
||||
db_cols = {c["name"] for c in inspector.get_columns(tbl_name)}
|
||||
missing = model_cols - db_cols
|
||||
if missing:
|
||||
print(f"[DB] Migrating {tbl_name}: adding {missing}")
|
||||
except Exception as e:
|
||||
print(f"[DB] Migration check: {e}")
|
||||
|
||||
|
||||
# ─── Session helpers ────────────────────────────────────────────────────────
|
||||
def get_db() -> Generator:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ─── Thread-safe sync session wrapper for async endpoints ──────────────────
|
||||
# Use this as a bridge: runs sync DB ops in executor to avoid blocking event loop.
|
||||
# The session is closed when the context exits.
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_executor():
|
||||
"""Async context manager wrapping sync SessionLocal in executor thread.
|
||||
Use for endpoints that haven't been fully async-converted yet."""
|
||||
loop = asyncio.get_running_loop()
|
||||
db = await loop.run_in_executor(None, lambda: SessionLocal())
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await loop.run_in_executor(None, db.close)
|
||||
|
||||
+1090
-618
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -12,18 +12,18 @@
|
||||
"cases": [
|
||||
"CS:GO Weapon Case",
|
||||
"Revolution Case",
|
||||
"Recoil Case"
|
||||
"Recoil Case",
|
||||
"Prisma 2 Case",
|
||||
"Prisma Case",
|
||||
"Fracture Case",
|
||||
"Danger Zone Case"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Новинки",
|
||||
"cases": [
|
||||
"allin1",
|
||||
"Danger Zone Case",
|
||||
"Prisma Case",
|
||||
"Fracture Case",
|
||||
"Prisma 2 Case",
|
||||
"m9orbutterfly1"
|
||||
"Revolvernoe"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Миграция данных из SQLite в PostgreSQL.
|
||||
Запуск: python migrate_db.py
|
||||
Требует настроенного PostgreSQL (см. database.py)
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from database import DATABASE_URL, ASYNC_DATABASE_URL, Base, User, InventoryItem, CaseOpening, Contract
|
||||
from database import Achievement, UserAchievement, ActivityFeed, UserRPU, UpgradeRPU, Upgrade, PromoCode, TransactionLog
|
||||
|
||||
# Sync SQLite source
|
||||
SOURCE_URL = DATABASE_URL
|
||||
# Sync PostgreSQL target (derive from async URL)
|
||||
TARGET_URL = ASYNC_DATABASE_URL.replace("+asyncpg", "").replace("+aiosqlite", "")
|
||||
|
||||
if "sqlite" in SOURCE_URL:
|
||||
print(f"Source: {SOURCE_URL}")
|
||||
print(f"Target: {TARGET_URL}")
|
||||
print("---")
|
||||
else:
|
||||
print("Source already PostgreSQL — nothing to migrate.")
|
||||
sys.exit(0)
|
||||
|
||||
source_engine = create_engine(SOURCE_URL)
|
||||
target_engine = create_engine(TARGET_URL, pool_pre_ping=True)
|
||||
|
||||
SourceSession = sessionmaker(bind=source_engine)
|
||||
TargetSession = sessionmaker(bind=target_engine)
|
||||
|
||||
def migrate_table(model, target_session):
|
||||
"""Copies all rows from source model to target model"""
|
||||
source_session = SourceSession()
|
||||
try:
|
||||
rows = source_session.query(model).all()
|
||||
if not rows:
|
||||
return 0
|
||||
count = 0
|
||||
for row in rows:
|
||||
# Detach from source session
|
||||
source_session.expunge(row)
|
||||
target_session.merge(row)
|
||||
count += 1
|
||||
target_session.commit()
|
||||
return count
|
||||
except Exception as e:
|
||||
target_session.rollback()
|
||||
print(f" ERROR: {e}")
|
||||
return 0
|
||||
finally:
|
||||
source_session.close()
|
||||
|
||||
def main():
|
||||
print("Creating target tables...")
|
||||
Base.metadata.create_all(bind=target_engine)
|
||||
|
||||
target_session = TargetSession()
|
||||
models = [
|
||||
(User, "users"),
|
||||
(UserRPU, "user_rpu"),
|
||||
(UpgradeRPU, "upgrade_rpu"),
|
||||
(InventoryItem, "inventory_items"),
|
||||
(CaseOpening, "case_openings"),
|
||||
(Contract, "contracts"),
|
||||
(Achievement, "achievements"),
|
||||
(UserAchievement, "user_achievements"),
|
||||
(ActivityFeed, "activity_feed"),
|
||||
(Upgrade, "upgrades"),
|
||||
(PromoCode, "promo_codes"),
|
||||
(TransactionLog, "transaction_log"),
|
||||
]
|
||||
|
||||
for model, name in models:
|
||||
print(f"Migrating {name}... ", end="", flush=True)
|
||||
count = migrate_table(model, target_session)
|
||||
print(f"{count} rows")
|
||||
|
||||
target_session.close()
|
||||
print("\nMigration complete! Set ASYNC_DATABASE_URL to the PostgreSQL URL to use it.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"phase": "LUCKY",
|
||||
"until": "2026-07-22T19:34:16.536290",
|
||||
"started_at": "2026-07-22T18:22:18.082890"
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Promo Phase — глобальное скрытое состояние "промо-фазы".
|
||||
Влияет на то, какой эффект дают промокоды (везёт / не везёт / нейтрально).
|
||||
Переключается случайно каждые 1-6 часов.
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
PHASE_FILE = Path("promo_phase.json")
|
||||
|
||||
PHASE_CONFIG = {
|
||||
"LUCKY": {
|
||||
"weight": 0.4,
|
||||
"min_hours": 1,
|
||||
"max_hours": 4,
|
||||
"effect": "boost",
|
||||
"boost_range": (0.15, 0.35), # +15-35% к luck_multiplier
|
||||
"rare_chance_boost": 0.05, # +5% к шансу редкого
|
||||
},
|
||||
"UNLUCKY": {
|
||||
"weight": 0.4,
|
||||
"min_hours": 1,
|
||||
"max_hours": 4,
|
||||
"effect": "penalty",
|
||||
"penalty_range": (0.10, 0.25), # -10-25% к luck_multiplier
|
||||
"ceiling_penalty": 0.10, # -10% к потолку
|
||||
},
|
||||
"NEUTRAL": {
|
||||
"weight": 0.2,
|
||||
"min_hours": 1,
|
||||
"max_hours": 3,
|
||||
"effect": "none",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def load_phase():
|
||||
"""Загружает текущую промо-фазу из файла"""
|
||||
if PHASE_FILE.exists():
|
||||
try:
|
||||
with open(PHASE_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"phase": "NEUTRAL",
|
||||
"until": datetime.utcnow().isoformat(),
|
||||
"started_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def save_phase(state: dict):
|
||||
"""Сохраняет промо-фазу"""
|
||||
with open(PHASE_FILE, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
|
||||
def pick_next_phase() -> str:
|
||||
"""Выбирает следующую фазу случайно, с учётом весов"""
|
||||
phases = list(PHASE_CONFIG.keys())
|
||||
weights = [PHASE_CONFIG[p]["weight"] for p in phases]
|
||||
return random.choices(phases, weights=weights, k=1)[0]
|
||||
|
||||
|
||||
def get_random_duration(phase: str) -> timedelta:
|
||||
"""Случайная длительность фазы"""
|
||||
config = PHASE_CONFIG.get(phase, PHASE_CONFIG["NEUTRAL"])
|
||||
min_h = config["min_hours"]
|
||||
max_h = config["max_hours"]
|
||||
hours = random.uniform(min_h, max_h)
|
||||
return timedelta(hours=hours)
|
||||
|
||||
|
||||
def ensure_phase():
|
||||
"""Проверяет и при необходимости переключает фазу"""
|
||||
state = load_phase()
|
||||
until = datetime.fromisoformat(state["until"])
|
||||
|
||||
if datetime.utcnow() >= until:
|
||||
new_phase = pick_next_phase()
|
||||
duration = get_random_duration(new_phase)
|
||||
state["phase"] = new_phase
|
||||
state["until"] = (datetime.utcnow() + duration).isoformat()
|
||||
state["started_at"] = datetime.utcnow().isoformat()
|
||||
save_phase(state)
|
||||
|
||||
return state["phase"]
|
||||
|
||||
|
||||
def get_current_phase() -> str:
|
||||
"""Возвращает текущую фазу"""
|
||||
return ensure_phase()
|
||||
|
||||
|
||||
def get_promo_effect_for_user(user_id: int, hot_score: float = 0.0,
|
||||
withdrawal_ratio: float = 0.0,
|
||||
is_cold: bool = False) -> dict:
|
||||
"""
|
||||
Возвращает индивидуальный эффект промо-фазы для пользователя.
|
||||
Одинаковая фаза → разный эффект в зависимости от hot/cold/withdrawal.
|
||||
"""
|
||||
phase = get_current_phase()
|
||||
config = PHASE_CONFIG.get(phase, {})
|
||||
effect_type = config.get("effect", "none")
|
||||
|
||||
result = {
|
||||
"phase": phase,
|
||||
"effect": effect_type,
|
||||
"luck_modifier": 0.0, # additive к luck_multiplier
|
||||
"ceiling_modifier": 0.0, # additive к ceiling
|
||||
"rare_chance_modifier": 0.0, # additive к шансу редкого
|
||||
"description": ""
|
||||
}
|
||||
|
||||
if effect_type == "boost":
|
||||
boost = random.uniform(*config["boost_range"])
|
||||
|
||||
# Холодные игроки получают БОЛЬШЕ буста (реактивация)
|
||||
if is_cold:
|
||||
boost *= 1.5
|
||||
# Кто много вывел — получает МЕНЬШЕ
|
||||
boost *= max(0.5, 1.0 - withdrawal_ratio * 0.5)
|
||||
|
||||
result["luck_modifier"] = boost
|
||||
result["rare_chance_modifier"] = config.get("rare_chance_boost", 0)
|
||||
result["description"] = "Промо-удача 🍀"
|
||||
|
||||
elif effect_type == "penalty":
|
||||
penalty = random.uniform(*config["penalty_range"])
|
||||
|
||||
# Горячие (много потратили, 0 выводов) почти не чувствуют пенальти
|
||||
if hot_score > 10000 and withdrawal_ratio < 0.1:
|
||||
penalty *= 0.3
|
||||
# Кто вывел много — получает ПОЛНУЮ открутку
|
||||
if withdrawal_ratio > 0.5:
|
||||
penalty *= 1.5
|
||||
|
||||
result["luck_modifier"] = -penalty
|
||||
result["ceiling_modifier"] = -config.get("ceiling_penalty", 0)
|
||||
result["description"] = "Промо-открутка 💀"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_promo_phase_info() -> dict:
|
||||
"""Возвращает инфо о фазе: phase, effect, next_switch, remaining_minutes."""
|
||||
state = load_phase()
|
||||
phase = state["phase"]
|
||||
until = datetime.fromisoformat(state["until"])
|
||||
config = PHASE_CONFIG.get(phase, {})
|
||||
remaining = (until - datetime.utcnow()).total_seconds()
|
||||
return {
|
||||
"phase": phase,
|
||||
"effect": config.get("effect", "none"),
|
||||
"next_switch": until.isoformat(),
|
||||
"remaining_minutes": max(0, int(remaining // 60)),
|
||||
}
|
||||
|
||||
|
||||
def get_phase_debug_info() -> dict:
|
||||
"""Для админки — отладка промо-фаз"""
|
||||
state = load_phase()
|
||||
phase = state["phase"]
|
||||
until = datetime.fromisoformat(state["until"])
|
||||
started = datetime.fromisoformat(state["started_at"])
|
||||
remaining = (until - datetime.utcnow()).total_seconds()
|
||||
|
||||
return {
|
||||
"current_phase": phase,
|
||||
"started_at": started.isoformat(),
|
||||
"until": until.isoformat(),
|
||||
"remaining_seconds": max(0, int(remaining)),
|
||||
"config": PHASE_CONFIG.get(phase, {})
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Promo Service — логика промокодов и депозитов.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from database import User, PromoCode, TransactionLog
|
||||
from bank_api import bank_client
|
||||
from promo_phase import get_promo_effect_for_user, get_current_phase
|
||||
|
||||
DAILY_DEPOSIT_LIMIT = 999999999.0 # без лимита
|
||||
|
||||
# ── Авто-генерация промокодов ──
|
||||
PROMO_WORDS = ["storm", "glitch", "frost", "blaze", "cipher", "nexus", "phantom", "prism"]
|
||||
PROMO_PERCENTS = [10, 15, 20, 21, 24, 25]
|
||||
|
||||
# Хранилище текущего авто-промокода (in-memory кэш)
|
||||
_current_auto_promo: Optional[dict] = None
|
||||
|
||||
|
||||
def generate_auto_promo_code() -> str:
|
||||
"""Генерирует промокод формата слово-процент, например 'storm-21'"""
|
||||
word = random.choice(PROMO_WORDS)
|
||||
pct = random.choice(PROMO_PERCENTS)
|
||||
return f"{word}-{pct}"
|
||||
|
||||
|
||||
def get_or_create_auto_promo(db: Session) -> PromoCode:
|
||||
"""
|
||||
Возвращает активный авто-сгенерированный промокод.
|
||||
Если его нет или он истёк (>1ч) — создаёт новый.
|
||||
"""
|
||||
global _current_auto_promo
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Ищем существующий авто-промокод, созданный <1 часа назад
|
||||
candidates = db.query(PromoCode).filter(
|
||||
PromoCode.reward_type == "card_to_site",
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.created_by == -1 # маркер авто-генерации
|
||||
).order_by(PromoCode.created_at.desc()).limit(1).all()
|
||||
|
||||
if candidates:
|
||||
promo = candidates[0]
|
||||
age = (now - promo.created_at).total_seconds()
|
||||
if age < 3600: # меньше часа — живой
|
||||
_current_auto_promo = {
|
||||
"code": promo.code,
|
||||
"reward_amount": promo.reward_amount,
|
||||
"created_at": promo.created_at.isoformat(),
|
||||
}
|
||||
return promo
|
||||
|
||||
# Создаём новый
|
||||
code = generate_auto_promo_code()
|
||||
pct = int(code.split("-")[1])
|
||||
reward = pct # процент от daily лимита как сумма бонуса
|
||||
|
||||
new_promo = PromoCode(
|
||||
code=code,
|
||||
reward_type="card_to_site",
|
||||
reward_amount=reward,
|
||||
reward_data="auto",
|
||||
max_uses=999,
|
||||
used_count=0,
|
||||
start_date=now,
|
||||
end_date=now + timedelta(hours=1),
|
||||
is_active=True,
|
||||
created_by=-1,
|
||||
)
|
||||
db.add(new_promo)
|
||||
db.commit()
|
||||
|
||||
_current_auto_promo = {
|
||||
"code": new_promo.code,
|
||||
"reward_amount": new_promo.reward_amount,
|
||||
"created_at": new_promo.created_at.isoformat(),
|
||||
}
|
||||
return new_promo
|
||||
|
||||
|
||||
def get_current_auto_promo(db: Session) -> Optional[dict]:
|
||||
"""Возвращает информацию о текущем авто-промокоде."""
|
||||
promo = get_or_create_auto_promo(db)
|
||||
now = datetime.utcnow()
|
||||
age = (now - promo.created_at).total_seconds()
|
||||
remaining = max(0, 3600 - age)
|
||||
return {
|
||||
"code": promo.code,
|
||||
"reward_amount": promo.reward_amount,
|
||||
"created_at": promo.created_at.isoformat(),
|
||||
"expires_in_seconds": int(remaining),
|
||||
"used_count": promo.used_count,
|
||||
}
|
||||
|
||||
|
||||
def validate_promo_code(db: Session, code: str) -> Optional[dict]:
|
||||
"""Проверяет промокод, возвращает данные или None"""
|
||||
promo = db.query(PromoCode).filter(
|
||||
PromoCode.code == code,
|
||||
PromoCode.is_active == True
|
||||
).first()
|
||||
|
||||
if not promo:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if promo.end_date and now > promo.end_date:
|
||||
return {"error": "Срок действия промокода истёк"}
|
||||
|
||||
if promo.start_date and now < promo.start_date:
|
||||
return {"error": "Промокод ещё не активен"}
|
||||
|
||||
if promo.max_uses > 0 and promo.used_count >= promo.max_uses:
|
||||
return {"error": "Промокод уже использован максимальное количество раз"}
|
||||
|
||||
return {
|
||||
"id": promo.id,
|
||||
"code": promo.code,
|
||||
"reward_type": promo.reward_type,
|
||||
"reward_amount": promo.reward_amount,
|
||||
"reward_data": promo.reward_data,
|
||||
}
|
||||
|
||||
|
||||
async def activate_promo_code(db: Session, user_id: int, code: str, amount: float = 0) -> dict:
|
||||
"""
|
||||
Активирует промокод.
|
||||
Типы: card_to_site, luck_boost, ceiling_boost, free_case, reset_streak
|
||||
"""
|
||||
promo_data = validate_promo_code(db, code)
|
||||
if not promo_data:
|
||||
return {"success": False, "error": "Промокод не найден"}
|
||||
if "error" in promo_data:
|
||||
return {"success": False, "error": promo_data["error"]}
|
||||
|
||||
promo = db.query(PromoCode).filter(PromoCode.id == promo_data["id"]).first()
|
||||
if not promo:
|
||||
return {"success": False, "error": "Ошибка загрузки промокода"}
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return {"success": False, "error": "Пользователь не найден"}
|
||||
|
||||
rpu = user.rpu_settings
|
||||
result = {"success": True, "code": code, "type": promo.reward_type}
|
||||
promo.used_count += 1
|
||||
|
||||
# Записываем транзакцию
|
||||
txn = TransactionLog(
|
||||
user_id=user_id,
|
||||
tx_type="promo_bonus",
|
||||
amount=0,
|
||||
promo_code=code,
|
||||
details=f"Промокод: {code} ({promo.reward_type})"
|
||||
)
|
||||
db.add(txn)
|
||||
|
||||
if promo.reward_type == "card_to_site":
|
||||
if amount <= 0:
|
||||
return {"success": False, "error": "Укажите сумму депозита"}
|
||||
|
||||
pct = promo.reward_amount # например 24 → 24%
|
||||
bonus = round(amount * pct / 100, 2)
|
||||
total = round(amount + bonus, 2)
|
||||
|
||||
# Проверяем баланс карты (требуем только сумму депозита)
|
||||
bank_balance = await bank_client.get_balance(user_id)
|
||||
|
||||
if bank_balance < amount:
|
||||
return {"success": False, "error": f"Недостаточно средств на карте. Нужно: {amount}₽"}
|
||||
|
||||
# Списываем с карты только депозит
|
||||
await bank_client.transfer_to_site(user_id, amount, promo_code=code)
|
||||
|
||||
# Зачисляем на сайт (депозит + бонус от платформы)
|
||||
user.balance += total
|
||||
user.total_deposited += amount
|
||||
|
||||
txn.amount = total
|
||||
txn.details = f"Депозит {amount}₽ + бонус {pct}% ({bonus}₽) по промокоду {code}"
|
||||
|
||||
result["amount"] = total
|
||||
result["deposit"] = amount
|
||||
result["bonus"] = bonus
|
||||
result["new_balance"] = user.balance
|
||||
|
||||
elif promo.reward_type == "luck_boost":
|
||||
boost = promo.reward_amount / 100.0 # например 20 → 0.2
|
||||
if rpu:
|
||||
rpu.luck_multiplier = min(3.0, rpu.luck_multiplier + boost)
|
||||
result["boost"] = boost
|
||||
|
||||
elif promo.reward_type == "ceiling_boost":
|
||||
boost_mult = promo.reward_amount / 100.0 + 1.0 # 50 → 1.5
|
||||
if rpu:
|
||||
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * boost_mult)
|
||||
result["ceiling_boost"] = boost_mult
|
||||
|
||||
elif promo.reward_type == "free_case":
|
||||
# Бесплатное открытие — просто отмечаем в результате
|
||||
result["free_case"] = promo.reward_data or ""
|
||||
|
||||
elif promo.reward_type == "reset_streak":
|
||||
if rpu:
|
||||
rpu.current_streak = 0
|
||||
rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
result["reset_streak"] = True
|
||||
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
async def direct_deposit(db: Session, user_id: int, amount: float, promo_code: str = "") -> dict:
|
||||
"""
|
||||
Прямой депозит с карты на сайт.
|
||||
Если указан промокод — применяет бонус поверх депозита.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return {"success": False, "error": "Сумма должна быть положительной"}
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return {"success": False, "error": "Пользователь не найден"}
|
||||
|
||||
# Проверка промокода (если указан)
|
||||
bonus = 0
|
||||
promo_pct = 0
|
||||
if promo_code:
|
||||
promo_data = validate_promo_code(db, promo_code)
|
||||
if promo_data and "error" not in promo_data:
|
||||
promo = db.query(PromoCode).filter(PromoCode.id == promo_data["id"]).first()
|
||||
if promo and promo.reward_type == "card_to_site":
|
||||
promo_pct = promo.reward_amount
|
||||
bonus = round(amount * promo_pct / 100, 2)
|
||||
promo.used_count += 1
|
||||
|
||||
total = round(amount + bonus, 2)
|
||||
|
||||
# Проверка дневного лимита
|
||||
today = datetime.utcnow().date()
|
||||
daily_total = db.query(TransactionLog).filter(
|
||||
TransactionLog.user_id == user_id,
|
||||
TransactionLog.tx_type == "card_to_site",
|
||||
).all()
|
||||
|
||||
today_deposits = sum(
|
||||
t.amount for t in daily_total
|
||||
if t.created_at.date() == today
|
||||
)
|
||||
|
||||
if today_deposits + amount > DAILY_DEPOSIT_LIMIT:
|
||||
remaining = max(0, DAILY_DEPOSIT_LIMIT - today_deposits)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Дневной лимит депозита: {remaining:.2f}₽"
|
||||
}
|
||||
|
||||
# Проверяем баланс карты
|
||||
bank_balance = await bank_client.get_balance(user_id)
|
||||
|
||||
if bank_balance < amount:
|
||||
return {"success": False, "error": "Недостаточно средств на карте"}
|
||||
|
||||
# Списываем с карты (только депозит, бонус от платформы)
|
||||
await bank_client.transfer_to_site(user_id, amount)
|
||||
|
||||
# Зачисляем на сайт
|
||||
user.balance += total
|
||||
user.total_deposited += amount
|
||||
user.last_deposit_date = datetime.utcnow()
|
||||
|
||||
details = f"Прямой депозит: +{amount}₽"
|
||||
if bonus > 0:
|
||||
details += f" + бонус {promo_pct}% ({bonus}₽)"
|
||||
|
||||
txn = TransactionLog(
|
||||
user_id=user_id,
|
||||
tx_type="card_to_site",
|
||||
amount=total,
|
||||
promo_code=promo_code if promo_code else None,
|
||||
details=details
|
||||
)
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"amount": total,
|
||||
"deposit": amount,
|
||||
"bonus": bonus,
|
||||
"new_balance": user.balance,
|
||||
"daily_remaining": max(0, DAILY_DEPOSIT_LIMIT - today_deposits - amount)
|
||||
}
|
||||
|
||||
|
||||
async def withdraw_item(db: Session, user_id: int, inventory_item_id: int) -> dict:
|
||||
"""
|
||||
Вывод скина: продажа предмета из инвентаря → зачисление на карту (с комиссией).
|
||||
"""
|
||||
from database import InventoryItem
|
||||
|
||||
item = db.query(InventoryItem).filter(
|
||||
InventoryItem.id == inventory_item_id,
|
||||
InventoryItem.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not item:
|
||||
return {"success": False, "error": "Предмет не найден в инвентаре"}
|
||||
|
||||
price = item.price_rub or 0
|
||||
if price <= 0:
|
||||
return {"success": False, "error": "Предмет не имеет цены"}
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
result = await bank_client.withdraw_to_card(user_id, price)
|
||||
|
||||
if not result["success"]:
|
||||
return {"success": False, "error": "Ошибка вывода"}
|
||||
|
||||
# Обновляем статистику пользователя
|
||||
user.total_withdrawn += result["net"] # net = price - fee
|
||||
db.delete(item)
|
||||
|
||||
txn = TransactionLog(
|
||||
user_id=user_id,
|
||||
tx_type="site_to_card",
|
||||
amount=price,
|
||||
fee=result["fee"],
|
||||
item_name=item.market_hash_name,
|
||||
inventory_item_id=inventory_item_id,
|
||||
details=f"Вывод: {item.market_hash_name} за {price}₽ (комиссия {result['fee']}₽)"
|
||||
)
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
|
||||
from rpu import check_withdrawal_penalty
|
||||
check_withdrawal_penalty(db, user_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"item_name": item.market_hash_name,
|
||||
"gross": price,
|
||||
"fee": result["fee"],
|
||||
"net": result["net"],
|
||||
"new_card_balance": result["new_card_balance"]
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Deploy to remote server, excluding DB and other local-only files
|
||||
rsync -av \
|
||||
--exclude '.git' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
--exclude '.gitignore' \
|
||||
--exclude 'cs2_simulator.db' \
|
||||
--exclude '*.log' \
|
||||
--exclude 'remote-deploy.sh' \
|
||||
--delete \
|
||||
/root/dodep-simulator/ \
|
||||
root@zernmc.ru:/root/dodep/
|
||||
|
||||
ssh root@zernmc.ru "cd /root/dodep && pip install --break-system-packages -r requirements.txt 2>&1 | tail -5 && pkill -f 'uvicorn' 2>/dev/null; pkill -f 'python3.*backend' 2>/dev/null; sleep 2; rm -rf __pycache__ && setsid python3 -m uvicorn frontend:app --host 0.0.0.0 --port 13669 > /tmp/frontend.log 2>&1 &"
|
||||
echo "Deployed and restarted."
|
||||
@@ -0,0 +1,16 @@
|
||||
fastapi>=0.139.0
|
||||
uvicorn>=0.50.0
|
||||
starlette>=1.3.1
|
||||
jinja2>=3.1.6
|
||||
markupsafe>=3.0.0
|
||||
sqlalchemy>=2.0
|
||||
python-jose[cryptography]
|
||||
passlib
|
||||
bcrypt>=4.0.0
|
||||
python-multipart
|
||||
python-dateutil
|
||||
aiofiles
|
||||
tqdm
|
||||
aiosqlite
|
||||
asyncpg
|
||||
redis>=5.0
|
||||
@@ -1,23 +1,20 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from database import User, UserRPU
|
||||
import math
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, Optional, Tuple
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database import User, UserRPU, InventoryItem
|
||||
from promo_phase import get_promo_effect_for_user, get_current_phase
|
||||
|
||||
# Стандартные веса редкостей
|
||||
BASE_RARITY_WEIGHTS = {
|
||||
"Consumer Grade": 10000,
|
||||
"Industrial Grade": 5000,
|
||||
"Mil-Spec": 2500,
|
||||
"Mil-Spec Grade": 2500,
|
||||
"Restricted": 625,
|
||||
"Classified": 125,
|
||||
"Covert": 25,
|
||||
"Rare Special Item": 5,
|
||||
"Extraordinary": 5
|
||||
"Consumer Grade": 10000, "Industrial Grade": 5000,
|
||||
"Mil-Spec": 2500, "Mil-Spec Grade": 2500,
|
||||
"Restricted": 625, "Classified": 125,
|
||||
"Covert": 25, "Rare Special Item": 5, "Extraordinary": 5
|
||||
}
|
||||
|
||||
# Маппинг редкостей на поля в UserRPU
|
||||
RARITY_TO_FIELD = {
|
||||
"Consumer Grade": "consumer_multiplier",
|
||||
"Industrial Grade": "industrial_multiplier",
|
||||
@@ -30,163 +27,484 @@ 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
|
||||
}
|
||||
|
||||
def get_user_rpu(db: Session, user_id: int) -> UserRPU:
|
||||
"""Получает или создает настройки РПУ для пользователя"""
|
||||
rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first()
|
||||
CEILING_BASE_MULTIPLIER = 3.0
|
||||
CEILING_MIN = 1000.0
|
||||
CEILING_SESSION_HISTORY = {0: 5.0, 1: 4.0, 2: 3.5, -1: 3.0}
|
||||
|
||||
BUDGET_MAX = 100.0
|
||||
BUDGET_CONSUMPTION_RATE = 0.001
|
||||
BUDGET_REGEN_PER_OPEN = 0.5
|
||||
BUDGET_LOW_THRESHOLD = 20.0
|
||||
|
||||
COMEBACK_TRIGGER_COUNT = 5
|
||||
COMEBACK_TRIGGER_VALUE_RATIO = 0.3
|
||||
COMEBACK_OPENINGS = 5
|
||||
COMEBACK_MULTIPLIER_MAX = 3.0
|
||||
|
||||
HOT_THRESHOLD_SPENT = 10000
|
||||
HOT_THRESHOLD_WITHDRAWAL = 0.1
|
||||
COLD_DAYS_INACTIVE = 7
|
||||
COLD_SPENT_PER_DAY = 100
|
||||
|
||||
_BATCH_MODE = False
|
||||
_BATCH_WEIGHTS_CACHE = {}
|
||||
|
||||
|
||||
def _rpu_commit_or_flush(db):
|
||||
if _BATCH_MODE:
|
||||
db.flush()
|
||||
else:
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _rpu_commit_or_flush_async(db: AsyncSession):
|
||||
if _BATCH_MODE:
|
||||
await db.flush()
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _batch_cache_get(user_id: int):
|
||||
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):
|
||||
if _BATCH_MODE:
|
||||
_BATCH_WEIGHTS_CACHE[user_id] = weights
|
||||
|
||||
|
||||
def _batch_cache_clear(user_id: int = None):
|
||||
if user_id is not None:
|
||||
_BATCH_WEIGHTS_CACHE.pop(user_id, None)
|
||||
else:
|
||||
_BATCH_WEIGHTS_CACHE.clear()
|
||||
|
||||
|
||||
async def get_user_rpu(db: AsyncSession, user_id: int) -> UserRPU:
|
||||
result = await db.execute(select(UserRPU).where(UserRPU.user_id == user_id))
|
||||
rpu = result.scalar_one_or_none()
|
||||
if not rpu:
|
||||
rpu = UserRPU(user_id=user_id)
|
||||
db.add(rpu)
|
||||
db.commit()
|
||||
db.refresh(rpu)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
await db.refresh(rpu)
|
||||
return rpu
|
||||
|
||||
|
||||
def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
|
||||
"""
|
||||
Получает веса редкостей с учетом РПУ пользователя.
|
||||
НИЗКИЙ % РПУ = ПОВЫШЕННЫЕ шансы на редкое (везёт)
|
||||
ВЫСОКИЙ % РПУ = ПОНИЖЕННЫЕ шансы на редкое (не везёт)
|
||||
"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
|
||||
# Вычисляем общий уровень РПУ (0-100%)
|
||||
rpu_level = calculate_rpu_level(rpu)
|
||||
|
||||
# Переворачиваем: низкий РПУ = высокий множитель для редких
|
||||
# При 0% РПУ множитель для редких = 2.0 (очень везёт)
|
||||
# При 50% РПУ множитель = 1.0 (обычно)
|
||||
# При 100% РПУ множитель = 0.1 (совсем не везёт)
|
||||
|
||||
adjusted = {}
|
||||
for rarity, base_weight in BASE_RARITY_WEIGHTS.items():
|
||||
field = RARITY_TO_FIELD.get(rarity)
|
||||
base_multiplier = getattr(rpu, field, 1.0) if field else 1.0
|
||||
|
||||
# Определяем "редкость" предмета (чем реже, тем больше индекс)
|
||||
rarity_index = 0
|
||||
if "Industrial" in rarity:
|
||||
rarity_index = 1
|
||||
elif "Mil-Spec" in rarity:
|
||||
rarity_index = 2
|
||||
elif "Restricted" in rarity:
|
||||
rarity_index = 3
|
||||
elif "Classified" in rarity:
|
||||
rarity_index = 4
|
||||
elif "Covert" in rarity:
|
||||
rarity_index = 5
|
||||
elif "Rare Special" in rarity or "Extraordinary" in rarity:
|
||||
rarity_index = 6
|
||||
|
||||
# Чем выше РПУ, тем сильнее режем шансы на редкое
|
||||
# Чем ниже РПУ, тем сильнее повышаем шансы на редкое
|
||||
if rpu_level < 50:
|
||||
# Везёт: повышаем шансы на редкое
|
||||
luck_factor = 1.0 + ((50 - rpu_level) / 50) * rarity_index * 0.3
|
||||
else:
|
||||
# Не везёт: понижаем шансы на редкое
|
||||
luck_factor = 1.0 - ((rpu_level - 50) / 50) * rarity_index * 0.2
|
||||
|
||||
luck_factor = max(0.1, min(3.0, luck_factor))
|
||||
|
||||
final_multiplier = base_multiplier * rpu.luck_multiplier * luck_factor
|
||||
adjusted[rarity] = base_weight * final_multiplier
|
||||
|
||||
return adjusted
|
||||
async def check_and_reset_session(db: AsyncSession, user_id: int):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
today = date.today()
|
||||
if rpu.session_reset_date is None:
|
||||
rpu.session_reset_date = datetime.utcnow()
|
||||
rpu.luck_budget = BUDGET_MAX
|
||||
rpu.session_spent = 0; rpu.session_won = 0
|
||||
rpu.ceiling_break_session = 0
|
||||
rpu.comeback_active = False; rpu.comeback_openings_left = 0
|
||||
rpu.comeback_multiplier = 1.0; rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
return
|
||||
last_reset = rpu.session_reset_date.date()
|
||||
if last_reset < today:
|
||||
rpu.session_reset_date = datetime.utcnow()
|
||||
rpu.luck_budget = BUDGET_MAX
|
||||
rpu.session_spent = 0; rpu.session_won = 0
|
||||
rpu.ceiling_break_session = 0
|
||||
rpu.comeback_active = False; rpu.comeback_openings_left = 0
|
||||
rpu.comeback_multiplier = 1.0; rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def calculate_ceiling(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return CEILING_MIN
|
||||
total_dep = max(0.0, user.total_deposited)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
session_count = 0
|
||||
if rpu.session_reset_date and user.created_at:
|
||||
days_since = (datetime.utcnow() - user.created_at).days
|
||||
session_count = max(0, days_since)
|
||||
mult = CEILING_SESSION_HISTORY.get(session_count, CEILING_SESSION_HISTORY[-1])
|
||||
mult *= rpu.ceiling_multiplier
|
||||
ceiling = total_dep * mult
|
||||
return max(CEILING_MIN, ceiling)
|
||||
|
||||
|
||||
async def get_total_inventory_value(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(InventoryItem.price_rub), 0))
|
||||
.where(InventoryItem.user_id == user_id)
|
||||
)
|
||||
return float(result.scalar() or 0)
|
||||
|
||||
|
||||
async def get_user_total_value(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
balance = user.balance if user else 0
|
||||
inv = await get_total_inventory_value(db, user_id)
|
||||
return balance + inv
|
||||
|
||||
|
||||
async def get_ceiling_luck_factor(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return 1.0
|
||||
ceiling = await calculate_ceiling(db, user_id)
|
||||
inv_value = await get_total_inventory_value(db, user_id)
|
||||
withdrawn = user.total_withdrawn or 0
|
||||
extracted_value = inv_value + withdrawn
|
||||
if ceiling <= 0:
|
||||
return 1.0
|
||||
ratio = extracted_value / ceiling
|
||||
if ratio < 0.8:
|
||||
return 1.0
|
||||
if ratio >= 1.0:
|
||||
return 0.3
|
||||
return 1.0 - (ratio - 0.8) / 0.2 * 0.7
|
||||
|
||||
|
||||
async def check_ceiling_breach(db: AsyncSession, user_id: int, item_price: float, case_price: float) -> bool:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
ceiling = await calculate_ceiling(db, user_id)
|
||||
total_value = user.balance + await get_total_inventory_value(db, user_id)
|
||||
if total_value < ceiling:
|
||||
return False
|
||||
if item_price < ceiling * 0.5:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def apply_ceiling_breach(db: AsyncSession, user_id: int, item_price: float):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5)
|
||||
rpu.ceiling_break_count += 1
|
||||
rpu.ceiling_break_session += 1
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def apply_ceiling_withdrawal_penalty(db: AsyncSession, user_id: int):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def consume_luck_budget(db: AsyncSession, user_id: int, item_price: float):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.luck_budget = max(0.0, rpu.luck_budget - item_price * BUDGET_CONSUMPTION_RATE)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def regen_luck_budget(db: AsyncSession, user_id: int):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.luck_budget = min(BUDGET_MAX, rpu.luck_budget + BUDGET_REGEN_PER_OPEN)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
def get_budget_factor(rpu: UserRPU) -> float:
|
||||
if rpu.luck_budget >= BUDGET_LOW_THRESHOLD:
|
||||
return 1.0
|
||||
return max(0.3, rpu.luck_budget / BUDGET_LOW_THRESHOLD)
|
||||
|
||||
|
||||
async def update_comeback_tracking(db: AsyncSession, user_id: int, spent: float, received_value: float):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return
|
||||
if received_value < spent * 0.8:
|
||||
rpu.consecutive_loss_count += 1
|
||||
rpu.consecutive_loss_value += (spent - received_value)
|
||||
else:
|
||||
rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
total_dep = max(1.0, user.total_deposited)
|
||||
loss_ratio = rpu.consecutive_loss_value / total_dep
|
||||
if (rpu.consecutive_loss_count >= COMEBACK_TRIGGER_COUNT or
|
||||
loss_ratio >= COMEBACK_TRIGGER_VALUE_RATIO):
|
||||
if not rpu.comeback_active:
|
||||
rpu.comeback_active = True
|
||||
rpu.comeback_openings_left = COMEBACK_OPENINGS
|
||||
rpu.comeback_multiplier = COMEBACK_MULTIPLIER_MAX
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
async def apply_comeback(db: AsyncSession, user_id: int) -> float:
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
if not rpu.comeback_active or rpu.comeback_openings_left <= 0:
|
||||
return 1.0
|
||||
mult = rpu.comeback_multiplier
|
||||
rpu.comeback_openings_left -= 1
|
||||
if rpu.comeback_openings_left <= 0:
|
||||
rpu.comeback_active = False
|
||||
rpu.comeback_multiplier = 1.0
|
||||
rpu.consecutive_loss_count = 0
|
||||
rpu.consecutive_loss_value = 0.0
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
return mult
|
||||
|
||||
|
||||
async def analyze_hot_cold(db: AsyncSession, user_id: int) -> Tuple[bool, bool, float]:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return False, True, 0.0
|
||||
total_dep = max(0.0, user.total_deposited)
|
||||
total_wd = max(0.0, user.total_withdrawn)
|
||||
wd_ratio = total_wd / max(1.0, total_dep)
|
||||
is_hot = (total_dep >= HOT_THRESHOLD_SPENT and wd_ratio < HOT_THRESHOLD_WITHDRAWAL)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
is_cold = False
|
||||
if rpu.last_activity_date:
|
||||
days_since = (datetime.utcnow() - rpu.last_activity_date).days
|
||||
if days_since >= COLD_DAYS_INACTIVE:
|
||||
is_cold = True
|
||||
else:
|
||||
is_cold = True
|
||||
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()
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
return is_hot, is_cold, wd_ratio
|
||||
|
||||
|
||||
async def check_withdrawal_penalty(db: AsyncSession, user_id: int):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return
|
||||
total_dep = max(1.0, user.total_deposited)
|
||||
total_wd = max(0.0, user.total_withdrawn)
|
||||
wd_ratio = total_wd / total_dep
|
||||
if wd_ratio > 0.5:
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - wd_ratio * 0.3)
|
||||
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
def calculate_rpu_level(rpu: UserRPU) -> float:
|
||||
"""
|
||||
Вычисляет общий уровень РПУ (0-100%).
|
||||
0% = максимальное везение
|
||||
100% = максимальное невезение
|
||||
"""
|
||||
multipliers = [
|
||||
rpu.consumer_multiplier,
|
||||
rpu.industrial_multiplier,
|
||||
rpu.mil_spec_multiplier,
|
||||
rpu.restricted_multiplier,
|
||||
rpu.classified_multiplier,
|
||||
rpu.covert_multiplier,
|
||||
rpu.consumer_multiplier, rpu.industrial_multiplier,
|
||||
rpu.mil_spec_multiplier, rpu.restricted_multiplier,
|
||||
rpu.classified_multiplier, rpu.covert_multiplier,
|
||||
rpu.rare_special_multiplier
|
||||
]
|
||||
|
||||
# Средний множитель
|
||||
avg = sum(multipliers) / len(multipliers)
|
||||
|
||||
# Преобразуем в проценты: 0.1x = 100% РПУ (не везёт), 3.0x = 0% РПУ (везёт)
|
||||
# 1.0x = 50% РПУ
|
||||
if avg >= 1.0:
|
||||
level = 50 - ((avg - 1.0) / 2.0) * 100
|
||||
else:
|
||||
level = 50 + ((1.0 - avg) / 0.9) * 50
|
||||
|
||||
return max(0, min(100, level))
|
||||
|
||||
|
||||
def get_rpu_description(level: float) -> str:
|
||||
"""Возвращает текстовое описание уровня РПУ"""
|
||||
if level < 20:
|
||||
return "🍀 Очень везучий"
|
||||
elif level < 35:
|
||||
return "😊 Везучий"
|
||||
elif level < 45:
|
||||
return "🙂 Немного везучий"
|
||||
elif level < 55:
|
||||
return "😐 Обычный"
|
||||
elif level < 70:
|
||||
return "🙁 Немного невезучий"
|
||||
elif level < 85:
|
||||
return "😟 Невезучий"
|
||||
else:
|
||||
return "💀 Проклятый"
|
||||
if level < 20: return "🍀 Очень везучий"
|
||||
elif level < 35: return "😊 Везучий"
|
||||
elif level < 45: return "🙂 Немного везучий"
|
||||
elif level < 55: return "😐 Обычный"
|
||||
elif level < 70: return "🙁 Немного невезучий"
|
||||
elif level < 85: return "😟 Невезучий"
|
||||
else: return "💀 Проклятый"
|
||||
|
||||
|
||||
def auto_adjust_rpu(db: Session, user_id: int):
|
||||
"""Автоматическая подкрутка РПУ на основе статистики"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
|
||||
async def get_adjusted_weights(db: AsyncSession, user_id: int) -> Dict[str, float]:
|
||||
cached = _batch_cache_get(user_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
await check_and_reset_session(db, user_id)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
level = calculate_rpu_level(rpu)
|
||||
is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id)
|
||||
|
||||
promo = get_promo_effect_for_user(user_id, rpu.hot_score, wd_ratio, is_cold)
|
||||
comeback_mult = await apply_comeback(db, user_id)
|
||||
budget_factor = get_budget_factor(rpu)
|
||||
ceiling_factor = await 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)
|
||||
|
||||
if level < 50:
|
||||
luck_factor = 1.0 + ((50 - level) / 50) * rarity_index * 0.3
|
||||
else:
|
||||
luck_factor = 1.0 - ((level - 50) / 50) * rarity_index * 0.2
|
||||
luck_factor = max(0.1, min(3.0, luck_factor))
|
||||
|
||||
final = base_multiplier * rpu.luck_multiplier * luck_factor
|
||||
final *= (1.0 + promo["luck_modifier"])
|
||||
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
|
||||
|
||||
|
||||
async def auto_adjust_rpu(db: AsyncSession, user_id: int):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
if not rpu.auto_adjust:
|
||||
return
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return
|
||||
|
||||
# Анализируем статистику
|
||||
if rpu.total_opened > 0:
|
||||
avg_spent_per_case = rpu.total_spent / rpu.total_opened
|
||||
|
||||
# Если пользователь много тратит и мало получает - снижаем РПУ (даём везение)
|
||||
if rpu.total_spent > 10000 and rpu.total_opened > 20:
|
||||
# Снижаем РПУ на 5-15%
|
||||
reduction = min(0.15, rpu.total_spent / 100000)
|
||||
rpu.luck_multiplier = min(2.0, rpu.luck_multiplier + reduction)
|
||||
|
||||
# Если пользователь часто открывает кейсы - немного снижаем РПУ
|
||||
if rpu.total_opened > 50:
|
||||
rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05)
|
||||
rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03)
|
||||
|
||||
# Ограничиваем множители
|
||||
|
||||
if rpu.total_opened > 0 and rpu.total_spent > 0:
|
||||
roi = rpu.total_value_received / rpu.total_spent
|
||||
if roi < 0.4:
|
||||
boost = (0.4 - roi) * 2
|
||||
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
|
||||
for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
||||
setattr(rpu, r, min(2.0, getattr(rpu, r) + boost * 0.3))
|
||||
elif roi > 2.0:
|
||||
penalty = (roi - 2.0) * 0.5
|
||||
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty)
|
||||
for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
||||
setattr(rpu, r, max(0.3, getattr(rpu, r) - penalty * 0.5))
|
||||
|
||||
streak = rpu.current_streak
|
||||
if streak <= -5:
|
||||
boost = min(0.3, abs(streak) * 0.03)
|
||||
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
|
||||
rpu.classified_multiplier = min(2.0, rpu.classified_multiplier + boost * 0.5)
|
||||
rpu.covert_multiplier = min(1.8, rpu.covert_multiplier + boost * 0.4)
|
||||
rpu.rare_special_multiplier = min(1.5, rpu.rare_special_multiplier + boost * 0.3)
|
||||
if streak >= 5:
|
||||
penalty = min(0.2, streak * 0.02)
|
||||
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty)
|
||||
for r in ['classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
||||
setattr(rpu, r, max(0.2, getattr(rpu, r) - penalty * 0.5))
|
||||
if rpu.total_opened > 50:
|
||||
rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05)
|
||||
rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03)
|
||||
|
||||
for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier',
|
||||
'restricted_multiplier', 'classified_multiplier', 'covert_multiplier',
|
||||
'rare_special_multiplier']:
|
||||
value = getattr(rpu, field)
|
||||
setattr(rpu, field, max(0.1, min(3.0, value)))
|
||||
|
||||
setattr(rpu, field, max(0.1, min(3.0, getattr(rpu, field))))
|
||||
rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier))
|
||||
rpu.last_adjustment = datetime.utcnow()
|
||||
db.commit()
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
|
||||
def update_rpu_stats(db: Session, user_id: int, spent: float, opened: int = 1):
|
||||
"""Обновляет статистику РПУ после открытия кейсов"""
|
||||
rpu = get_user_rpu(db, user_id)
|
||||
async def update_rpu_stats(db: AsyncSession, user_id: int, spent: float, opened: int = 1,
|
||||
received_value: float = 0.0, rarity: str = None, is_rare: bool = False):
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
rpu.total_spent += spent
|
||||
rpu.total_opened += opened
|
||||
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
|
||||
|
||||
await consume_luck_budget(db, user_id, received_value)
|
||||
await regen_luck_budget(db, user_id)
|
||||
await update_comeback_tracking(db, user_id, spent, received_value)
|
||||
|
||||
if user and is_rare and received_value > 0:
|
||||
if await check_ceiling_breach(db, user_id, received_value, spent):
|
||||
await apply_ceiling_breach(db, user_id, received_value)
|
||||
|
||||
await analyze_hot_cold(db, user_id)
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
if rpu.auto_adjust:
|
||||
auto_adjust_rpu(db, user_id)
|
||||
await auto_adjust_rpu(db, user_id)
|
||||
|
||||
|
||||
async def get_rpu_v2_info(db: AsyncSession, user_id: int) -> dict:
|
||||
await check_and_reset_session(db, user_id)
|
||||
rpu = await get_user_rpu(db, user_id)
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
ceiling = await calculate_ceiling(db, user_id)
|
||||
inv_value = await get_total_inventory_value(db, user_id)
|
||||
total_value = user.balance + inv_value
|
||||
is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id)
|
||||
|
||||
session_count = max(0, (datetime.utcnow() - user.created_at).days) if user.created_at else 0
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# CS2 Simulator — Production Start Script
|
||||
# Starts: PostgreSQL-backed web app + WebSocket server
|
||||
|
||||
set -e
|
||||
|
||||
# Ensure PostgreSQL is running
|
||||
pg_isready -q || service postgresql start
|
||||
|
||||
# Ensure Redis is running
|
||||
redis-cli ping > /dev/null 2>&1 || redis-server --daemonize yes
|
||||
|
||||
# Set production env
|
||||
export USE_ASYNC=1
|
||||
export ASYNC_DATABASE_URL="${ASYNC_DATABASE_URL:-postgresql+asyncpg://dodep:dodep123@localhost/dodep}"
|
||||
export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}"
|
||||
|
||||
echo "=== Starting CS2 Simulator ==="
|
||||
echo "DB: $ASYNC_DATABASE_URL"
|
||||
echo "WS Server: port 13670"
|
||||
echo "Web App: port 13669"
|
||||
|
||||
# Kill old processes
|
||||
pkill -f "uvicorn frontend:app" 2>/dev/null || true
|
||||
pkill -f "uvicorn ws_server:app" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Start WebSocket server
|
||||
nohup venv/bin/python -m uvicorn ws_server:app --host 0.0.0.0 --port 13670 --workers 1 > ws_server.log 2>&1 &
|
||||
echo "[WS] Started on port 13670"
|
||||
|
||||
# Start main web app with multiple workers
|
||||
nohup venv/bin/python -m uvicorn frontend:app --host 0.0.0.0 --port 13669 --workers 4 --loop uvloop > frontend.log 2>&1 &
|
||||
echo "[WEB] Started on port 13669 (4 workers)"
|
||||
|
||||
echo "=== Ready ==="
|
||||
echo "Web: http://localhost:13669"
|
||||
echo "WS: ws://localhost:13670/ws/{user_id}"
|
||||
+214
-43
@@ -3984,49 +3984,6 @@ h1, h2, h3, h4, h5, h6, .logo, .nav-link, .btn {
|
||||
|
||||
/* Результат */
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
pointer-events: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 4px 20px rgba(16,185,129,0.3);
|
||||
}
|
||||
|
||||
.toast.fail {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 4px 20px rgba(239,68,68,0.2);
|
||||
}
|
||||
|
||||
.upgrade-page .result-item-display img {
|
||||
width: 72px;
|
||||
height: 60px;
|
||||
@@ -4486,4 +4443,218 @@ a:not(.btn):not(.nav-link):not(.logo) {
|
||||
}
|
||||
a:not(.btn):not(.nav-link):not(.logo):hover {
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
🍞 Уведомления (Toast + Confirm)
|
||||
============================================= */
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
animation: toastSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
transform-origin: top right;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
|
||||
.toast-success::before { background: var(--success); }
|
||||
.toast-error::before { background: var(--danger); }
|
||||
.toast-info::before { background: var(--primary); }
|
||||
.toast-warning::before { background: var(--warning); }
|
||||
|
||||
.toast-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon { background: rgba(16,185,129,0.15); }
|
||||
.toast-error .toast-icon { background: rgba(239,68,68,0.15); }
|
||||
.toast-info .toast-icon { background: rgba(245,158,11,0.15); }
|
||||
.toast-warning .toast-icon { background: rgba(245,158,11,0.15); }
|
||||
|
||||
.toast-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toast-removing {
|
||||
animation: toastSlideOut 0.25s cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from { opacity: 0; transform: translateX(100%) scale(0.9); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut {
|
||||
from { opacity: 1; transform: translateX(0) scale(1); }
|
||||
to { opacity: 0; transform: translateX(100%) scale(0.9); }
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
⚠️ Confirm диалог
|
||||
============================================= */
|
||||
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: confirmFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
animation: confirmScaleIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.confirm-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.confirm-dialog-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirm-dialog-icon-warning { background: rgba(239,68,68,0.15); }
|
||||
.confirm-dialog-icon-info { background: rgba(245,158,11,0.15); }
|
||||
|
||||
.confirm-dialog-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.confirm-dialog-message {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 46px;
|
||||
}
|
||||
|
||||
.confirm-dialog-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.confirm-dialog-actions .btn {
|
||||
min-width: 90px;
|
||||
padding: 0.55rem 1.2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.confirm-dialog .btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.confirm-dialog .btn-danger:hover {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 4px 16px rgba(239,68,68,0.3);
|
||||
}
|
||||
|
||||
@keyframes confirmFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes confirmScaleIn {
|
||||
from { opacity: 0; transform: scale(0.9) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+5
-4
@@ -14,9 +14,10 @@ async function refreshBalance() {
|
||||
const res = await fetch('/web/api/user/balance');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.balance)} ₽`;
|
||||
});
|
||||
const el = document.getElementById('siteBalanceDisplay');
|
||||
if (el) {
|
||||
el.innerHTML = Math.floor(data.balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -140,7 +141,7 @@ function showAchievementPopup(title, reward) {
|
||||
}
|
||||
|
||||
document.getElementById('globalPopupTitle').textContent = title;
|
||||
document.getElementById('globalPopupReward').textContent = reward ? `+${reward} ₽` : '';
|
||||
document.getElementById('globalPopupReward').textContent = '';
|
||||
popup.style.display = 'block';
|
||||
|
||||
if (window.SoundManager) SoundManager.achievement();
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
async function logout(){try{await fetch('/web/api/auth/logout',{method:'POST'});}catch(e){}
|
||||
window.location.href='/';}
|
||||
async function refreshBalance(){try{const res=await fetch('/web/api/user/balance');const data=await res.json();if(data.success){const el=document.getElementById('siteBalanceDisplay');if(el){el.innerHTML=Math.floor(data.balance).toLocaleString('ru')+' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';}}}catch(e){}}
|
||||
function getRarityColor(rarity){const colors={'Consumer Grade':'#b0b0b0','Industrial Grade':'#5e98d9','Mil-Spec':'#4b69ff','Mil-Spec Grade':'#4b69ff','Restricted':'#8847ff','Classified':'#d32ce6','Covert':'#eb4b4b','Rare Special Item':'#ffd700','Extraordinary':'#ffd700',};return colors[rarity]||'#b0b0b0';}
|
||||
function formatNumber(n){return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g,' ');}
|
||||
function debounce(fn,ms=300){let timer;return function(...args){clearTimeout(timer);timer=setTimeout(()=>fn.apply(this,args),ms);};}
|
||||
function escapeHtml(str){const div=document.createElement('div');div.textContent=str;return div.innerHTML;}
|
||||
function showNotification(message,type='info',duration=3000){const existing=document.querySelector('.notification-toast');if(existing)existing.remove();const colors={success:'#22c55e',error:'#ef4444',info:'#3b82f6',warning:'#f59e0b',};const toast=document.createElement('div');toast.className='notification-toast';toast.style.cssText=`position:fixed;top:20px;right:20px;z-index:99999;background:#1a1a2e;border:1px solid ${colors[type]||colors.info};color:white;padding:12px 20px;border-radius:8px;font-size:14px;box-shadow:0 10px 40px rgba(0,0,0,0.5);animation:slideInRight 0.3s ease;max-width:400px;`;toast.textContent=message;document.body.appendChild(toast);setTimeout(()=>{toast.style.animation='slideOutRight 0.3s ease forwards';setTimeout(()=>toast.remove(),300);},duration);}
|
||||
if(!document.getElementById('notificationStyles')){const style=document.createElement('style');style.id='notificationStyles';style.textContent=`@keyframes slideInRight{from{transform:translateX(120%);opacity:0;}
|
||||
to{transform:translateX(0);opacity:1;}}@keyframes slideOutRight{from{transform:translateX(0);opacity:1;}
|
||||
to{transform:translateX(120%);opacity:0;}}`;document.head.appendChild(style);}
|
||||
function showAchievementPopup(title,reward){let popup=document.getElementById('achievementGlobalPopup');if(!popup){popup=document.createElement('div');popup.id='achievementGlobalPopup';popup.style.cssText=`position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:99999;background:linear-gradient(135deg,#1a3a1a,#0d260d);border:1px solid#22c55e;border-radius:12px;padding:1rem 1.5rem;box-shadow:0 10px 40px rgba(0,0,0,0.5);animation:slideInDown 0.5s ease;max-width:400px;width:90%;text-align:center;`;popup.innerHTML=`<div style="font-size:0.85rem;color:#94a3b8;margin-bottom:0.25rem;">🏆 Новое достижение!</div><div style="font-size:1.1rem;font-weight:600;"id="globalPopupTitle"></div><div style="color:#22c55e;font-size:0.9rem;margin-top:0.25rem;"id="globalPopupReward"></div>`;document.body.appendChild(popup);if(!document.getElementById('slideInDownStyle')){const s=document.createElement('style');s.id='slideInDownStyle';s.textContent=`@keyframes slideInDown{from{transform:translateX(-50%)translateY(-120%);opacity:0;}
|
||||
to{transform:translateX(-50%)translateY(0);opacity:1;}}`;document.head.appendChild(s);}}
|
||||
document.getElementById('globalPopupTitle').textContent=title;document.getElementById('globalPopupReward').textContent='';popup.style.display='block';if(window.SoundManager)SoundManager.achievement();setTimeout(()=>{popup.style.animation='slideInDown 0.5s ease reverse forwards';setTimeout(()=>{popup.style.display='none';popup.style.animation='slideInDown 0.5s ease';},500);},4000);}
|
||||
@@ -0,0 +1,137 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
container.id = 'toastContainer';
|
||||
document.body.appendChild(container);
|
||||
|
||||
function escapeHtml(text) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = text || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
window.Notify = {
|
||||
toast: function(message, type, title) {
|
||||
if (!message) return;
|
||||
type = type || 'info';
|
||||
const icons = { success: '✓', error: '✕', info: 'ℹ', warning: '⚠' };
|
||||
const titles = { success: title || 'Успешно', error: title || 'Ошибка', info: title || 'Информация', warning: title || 'Внимание' };
|
||||
const icon = icons[type] || 'ℹ';
|
||||
const t = titles[type];
|
||||
|
||||
const id = ++toastId;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast toast-' + type;
|
||||
el.id = 'toast-' + id;
|
||||
el.innerHTML =
|
||||
'<div class="toast-icon">' + icon + '</div>' +
|
||||
'<div class="toast-content">' +
|
||||
'<div class="toast-title">' + escapeHtml(t) + '</div>' +
|
||||
'<div class="toast-message">' + escapeHtml(message) + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="toast-close" onclick="Notify.dismiss(' + id + ')">✕</button>';
|
||||
|
||||
container.appendChild(el);
|
||||
|
||||
const timeout = setTimeout(function() {
|
||||
Notify.dismiss(id);
|
||||
}, 4000);
|
||||
|
||||
el._timeout = timeout;
|
||||
el._id = id;
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
success: function(message) {
|
||||
return this.toast(message, 'success');
|
||||
},
|
||||
|
||||
error: function(message) {
|
||||
return this.toast(message, 'error');
|
||||
},
|
||||
|
||||
info: function(message) {
|
||||
return this.toast(message, 'info');
|
||||
},
|
||||
|
||||
warning: function(message) {
|
||||
return this.toast(message, 'warning');
|
||||
},
|
||||
|
||||
dismiss: function(id) {
|
||||
var el = document.getElementById('toast-' + id);
|
||||
if (!el) return;
|
||||
if (el._timeout) clearTimeout(el._timeout);
|
||||
if (el.classList.contains('toast-removing')) return;
|
||||
el.classList.add('toast-removing');
|
||||
setTimeout(function() {
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
}, 250);
|
||||
},
|
||||
|
||||
confirm: function(message, title, callback) {
|
||||
if (typeof title === 'function') {
|
||||
callback = title;
|
||||
title = 'Подтверждение';
|
||||
}
|
||||
title = title || 'Подтверждение';
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML =
|
||||
'<div class="confirm-dialog">' +
|
||||
'<div class="confirm-dialog-header">' +
|
||||
'<div class="confirm-dialog-icon confirm-dialog-icon-warning">⚠</div>' +
|
||||
'<div class="confirm-dialog-title">' + escapeHtml(title) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="confirm-dialog-message">' + escapeHtml(message) + '</div>' +
|
||||
'<div class="confirm-dialog-actions">' +
|
||||
'<button class="btn btn-outline confirm-cancel">Отмена</button>' +
|
||||
'<button class="btn btn-danger confirm-ok">Подтвердить</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var result = false;
|
||||
|
||||
function close() {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
if (callback) callback(result);
|
||||
}
|
||||
|
||||
overlay.querySelector('.confirm-cancel').addEventListener('click', function() {
|
||||
result = false;
|
||||
close();
|
||||
});
|
||||
|
||||
overlay.querySelector('.confirm-ok').addEventListener('click', function() {
|
||||
result = true;
|
||||
close();
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) {
|
||||
result = false;
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function handler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
result = false;
|
||||
close();
|
||||
document.removeEventListener('keydown', handler);
|
||||
}
|
||||
});
|
||||
|
||||
return overlay;
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
(function(){'use strict';let toastId=0;const container=document.createElement('div');container.className='toast-container';container.id='toastContainer';document.body.appendChild(container);function escapeHtml(text){const d=document.createElement('div');d.textContent=text||'';return d.innerHTML;}
|
||||
window.Notify={toast:function(message,type,title){if(!message)return;type=type||'info';const icons={success:'✓',error:'✕',info:'ℹ',warning:'⚠'};const titles={success:title||'Успешно',error:title||'Ошибка',info:title||'Информация',warning:title||'Внимание'};const icon=icons[type]||'ℹ';const t=titles[type];const id=++toastId;const el=document.createElement('div');el.className='toast toast-'+type;el.id='toast-'+id;el.innerHTML='<div class="toast-icon">'+icon+'</div>'+
|
||||
'<div class="toast-content">'+
|
||||
'<div class="toast-title">'+escapeHtml(t)+'</div>'+
|
||||
'<div class="toast-message">'+escapeHtml(message)+'</div>'+
|
||||
'</div>'+
|
||||
'<button class="toast-close" onclick="Notify.dismiss('+id+')">✕</button>';container.appendChild(el);const timeout=setTimeout(function(){Notify.dismiss(id);},4000);el._timeout=timeout;el._id=id;return id;},success:function(message){return this.toast(message,'success');},error:function(message){return this.toast(message,'error');},info:function(message){return this.toast(message,'info');},warning:function(message){return this.toast(message,'warning');},dismiss:function(id){var el=document.getElementById('toast-'+id);if(!el)return;if(el._timeout)clearTimeout(el._timeout);if(el.classList.contains('toast-removing'))return;el.classList.add('toast-removing');setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},250);},confirm:function(message,title,callback){if(typeof title==='function'){callback=title;title='Подтверждение';}
|
||||
title=title||'Подтверждение';var overlay=document.createElement('div');overlay.className='confirm-overlay';overlay.innerHTML='<div class="confirm-dialog">'+
|
||||
'<div class="confirm-dialog-header">'+
|
||||
'<div class="confirm-dialog-icon confirm-dialog-icon-warning">⚠</div>'+
|
||||
'<div class="confirm-dialog-title">'+escapeHtml(title)+'</div>'+
|
||||
'</div>'+
|
||||
'<div class="confirm-dialog-message">'+escapeHtml(message)+'</div>'+
|
||||
'<div class="confirm-dialog-actions">'+
|
||||
'<button class="btn btn-outline confirm-cancel">Отмена</button>'+
|
||||
'<button class="btn btn-danger confirm-ok">Подтвердить</button>'+
|
||||
'</div>'+
|
||||
'</div>';document.body.appendChild(overlay);var result=false;function close(){if(overlay.parentNode)overlay.parentNode.removeChild(overlay);if(callback)callback(result);}
|
||||
overlay.querySelector('.confirm-cancel').addEventListener('click',function(){result=false;close();});overlay.querySelector('.confirm-ok').addEventListener('click',function(){result=true;close();});overlay.addEventListener('click',function(e){if(e.target===overlay){result=false;close();}});document.addEventListener('keydown',function handler(e){if(e.key==='Escape'){result=false;close();document.removeEventListener('keydown',handler);}});return overlay;}};})();
|
||||
+88
-49
@@ -1,8 +1,9 @@
|
||||
// Sound effects system using Web Audio API - no external files needed
|
||||
const SoundManager = {
|
||||
_ctx: null,
|
||||
_enabled: true,
|
||||
_volume: 0.3,
|
||||
_activeNodes: new Set(),
|
||||
_timeouts: [],
|
||||
|
||||
init() {
|
||||
try {
|
||||
@@ -25,10 +26,28 @@ const SoundManager = {
|
||||
return this._enabled;
|
||||
},
|
||||
|
||||
stopAll() {
|
||||
this._timeouts.forEach(clearTimeout);
|
||||
this._timeouts = [];
|
||||
this._activeNodes.forEach(node => {
|
||||
try { node.stop(); } catch (e) {}
|
||||
try { node.disconnect(); } catch (e) {}
|
||||
});
|
||||
this._activeNodes.clear();
|
||||
},
|
||||
|
||||
_trackNode(node, duration) {
|
||||
this._activeNodes.add(node);
|
||||
node.addEventListener('ended', () => this._activeNodes.delete(node));
|
||||
setTimeout(() => {
|
||||
this._activeNodes.delete(node);
|
||||
try { node.disconnect(); } catch (e) {}
|
||||
}, (duration + 0.1) * 1000);
|
||||
},
|
||||
|
||||
_play(freq, duration, type = 'sine', volume = 1) {
|
||||
if (!this._enabled || !this._ctx) return;
|
||||
if (this._ctx.state === 'suspended') this._ctx.resume();
|
||||
|
||||
const osc = this._ctx.createOscillator();
|
||||
const gain = this._ctx.createGain();
|
||||
osc.type = type;
|
||||
@@ -39,12 +58,13 @@ const SoundManager = {
|
||||
gain.connect(this._ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(this._ctx.currentTime + duration);
|
||||
this._trackNode(osc, duration);
|
||||
this._trackNode(gain, duration);
|
||||
},
|
||||
|
||||
_noise(duration, volume = 1) {
|
||||
if (!this._enabled || !this._ctx) return;
|
||||
if (this._ctx.state === 'suspended') this._ctx.resume();
|
||||
|
||||
const bufferSize = this._ctx.sampleRate * duration;
|
||||
const buffer = this._ctx.createBuffer(1, bufferSize, this._ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
@@ -59,91 +79,113 @@ const SoundManager = {
|
||||
source.connect(gain);
|
||||
gain.connect(this._ctx.destination);
|
||||
source.start();
|
||||
this._trackNode(source, duration);
|
||||
this._trackNode(gain, duration);
|
||||
},
|
||||
|
||||
_delay(fn, ms) {
|
||||
const id = setTimeout(() => {
|
||||
this._timeouts = this._timeouts.filter(t => t !== id);
|
||||
fn();
|
||||
}, ms);
|
||||
this._timeouts.push(id);
|
||||
return id;
|
||||
},
|
||||
|
||||
caseOpen() {
|
||||
// Descending sweep with click
|
||||
this._play(800, 0.1, 'square', 0.3);
|
||||
setTimeout(() => this._play(400, 0.15, 'sawtooth', 0.2), 80);
|
||||
setTimeout(() => this._play(200, 0.2, 'sine', 0.15), 180);
|
||||
this._delay(() => this._play(400, 0.15, 'sawtooth', 0.2), 80);
|
||||
this._delay(() => this._play(200, 0.2, 'sine', 0.15), 180);
|
||||
this._noise(0.05, 0.4);
|
||||
},
|
||||
|
||||
caseRare() {
|
||||
// Ascending bright chime for rare items
|
||||
this._play(523, 0.15, 'sine', 0.3);
|
||||
setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 100);
|
||||
setTimeout(() => this._play(784, 0.15, 'sine', 0.3), 200);
|
||||
setTimeout(() => this._play(1047, 0.3, 'sine', 0.4), 300);
|
||||
this._delay(() => this._play(659, 0.15, 'sine', 0.3), 100);
|
||||
this._delay(() => this._play(784, 0.15, 'sine', 0.3), 200);
|
||||
this._delay(() => this._play(1047, 0.3, 'sine', 0.4), 300);
|
||||
},
|
||||
|
||||
caseCovert() {
|
||||
// Epic sound for covert/knife
|
||||
this._play(392, 0.2, 'sawtooth', 0.3);
|
||||
setTimeout(() => this._play(523, 0.2, 'sawtooth', 0.3), 150);
|
||||
setTimeout(() => this._play(659, 0.2, 'sawtooth', 0.3), 300);
|
||||
setTimeout(() => this._play(784, 0.4, 'sine', 0.5), 450);
|
||||
setTimeout(() => this._play(1047, 0.6, 'sine', 0.6), 600);
|
||||
this._delay(() => this._play(523, 0.2, 'sawtooth', 0.3), 150);
|
||||
this._delay(() => this._play(659, 0.2, 'sawtooth', 0.3), 300);
|
||||
this._delay(() => this._play(784, 0.4, 'sine', 0.5), 450);
|
||||
this._delay(() => this._play(1047, 0.6, 'sine', 0.6), 600);
|
||||
},
|
||||
|
||||
slotSpin() {
|
||||
// Ratcheting sound for slot reels
|
||||
for (let i = 0; i < 8; i++) {
|
||||
setTimeout(() => {
|
||||
this._delay(() => {
|
||||
this._play(300 + Math.random() * 400, 0.05, 'square', 0.15);
|
||||
}, i * 80);
|
||||
}
|
||||
},
|
||||
|
||||
slotMatch() {
|
||||
// Win jingle
|
||||
this._play(523, 0.1, 'sine', 0.3);
|
||||
setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 100);
|
||||
setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200);
|
||||
setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 300);
|
||||
this._delay(() => this._play(659, 0.1, 'sine', 0.3), 100);
|
||||
this._delay(() => this._play(784, 0.1, 'sine', 0.3), 200);
|
||||
this._delay(() => this._play(1047, 0.4, 'sine', 0.5), 300);
|
||||
},
|
||||
|
||||
contractSubmit() {
|
||||
// Mechanical sound
|
||||
this._play(150, 0.3, 'sawtooth', 0.2);
|
||||
setTimeout(() => this._play(200, 0.2, 'square', 0.2), 200);
|
||||
setTimeout(() => this._play(250, 0.1, 'square', 0.15), 350);
|
||||
this._delay(() => this._play(200, 0.2, 'square', 0.2), 200);
|
||||
this._delay(() => this._play(250, 0.1, 'square', 0.15), 350);
|
||||
},
|
||||
|
||||
contractResult() {
|
||||
// Rising success
|
||||
this._play(400, 0.15, 'sine', 0.3);
|
||||
setTimeout(() => this._play(600, 0.15, 'sine', 0.3), 120);
|
||||
setTimeout(() => this._play(800, 0.3, 'sine', 0.4), 240);
|
||||
this._delay(() => this._play(600, 0.15, 'sine', 0.3), 120);
|
||||
this._delay(() => this._play(800, 0.3, 'sine', 0.4), 240);
|
||||
},
|
||||
|
||||
wheelSpin() {
|
||||
// Ticking as wheel rotates
|
||||
this._play(200, 0.03, 'square', 0.1);
|
||||
this._play(600 + Math.random() * 400, 0.04, 'triangle', 0.12);
|
||||
this._play(200, 0.02, 'square', 0.06);
|
||||
},
|
||||
|
||||
wheelWin() {
|
||||
// Celebration
|
||||
this._play(523, 0.15, 'sine', 0.3);
|
||||
setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 120);
|
||||
setTimeout(() => this._play(784, 0.2, 'sine', 0.35), 240);
|
||||
setTimeout(() => this._play(1047, 0.5, 'sine', 0.5), 360);
|
||||
const t = this._ctx.currentTime;
|
||||
if (!this._enabled || !this._ctx) return;
|
||||
if (this._ctx.state === 'suspended') this._ctx.resume();
|
||||
[523, 659, 784, 1047].forEach((freq, i) => {
|
||||
const osc = this._ctx.createOscillator();
|
||||
const gain = this._ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, t + i * 0.1);
|
||||
gain.gain.setValueAtTime(0, t + i * 0.1);
|
||||
gain.gain.linearRampToValueAtTime(this._volume * 0.35, t + i * 0.1 + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + i * 0.1 + 0.5);
|
||||
osc.connect(gain);
|
||||
gain.connect(this._ctx.destination);
|
||||
osc.start(t + i * 0.1);
|
||||
osc.stop(t + i * 0.1 + 0.5);
|
||||
this._trackNode(osc, 0.6);
|
||||
this._trackNode(gain, 0.6);
|
||||
});
|
||||
this._delay(() => this._play(1568, 0.4, 'sine', 0.25), 350);
|
||||
this._delay(() => this._play(2093, 0.6, 'sine', 0.2), 500);
|
||||
},
|
||||
|
||||
wheelLose() {
|
||||
// Sad trombone
|
||||
this._play(400, 0.2, 'sine', 0.2);
|
||||
setTimeout(() => this._play(350, 0.2, 'sine', 0.2), 150);
|
||||
setTimeout(() => this._play(300, 0.3, 'sine', 0.15), 300);
|
||||
this._play(300, 0.15, 'sawtooth', 0.25);
|
||||
this._delay(() => this._play(200, 0.2, 'sawtooth', 0.2), 100);
|
||||
this._delay(() => this._play(120, 0.3, 'sawtooth', 0.15), 220);
|
||||
this._noise(0.15, 0.3);
|
||||
this._delay(() => {
|
||||
this._play(60, 0.5, 'sine', 0.2);
|
||||
this._play(45, 0.6, 'sine', 0.15);
|
||||
}, 350);
|
||||
},
|
||||
|
||||
achievement() {
|
||||
// Achievement unlock
|
||||
this._play(659, 0.1, 'sine', 0.3);
|
||||
setTimeout(() => this._play(523, 0.1, 'sine', 0.3), 100);
|
||||
setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200);
|
||||
setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 300);
|
||||
setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 400);
|
||||
this._delay(() => this._play(523, 0.1, 'sine', 0.3), 100);
|
||||
this._delay(() => this._play(784, 0.1, 'sine', 0.3), 200);
|
||||
this._delay(() => this._play(659, 0.1, 'sine', 0.3), 300);
|
||||
this._delay(() => this._play(1047, 0.4, 'sine', 0.5), 400);
|
||||
},
|
||||
|
||||
crashBet() {
|
||||
@@ -155,17 +197,15 @@ const SoundManager = {
|
||||
},
|
||||
|
||||
crashCrashed() {
|
||||
// Explosion
|
||||
this._noise(0.3, 0.6);
|
||||
this._play(80, 0.5, 'sawtooth', 0.4);
|
||||
setTimeout(() => this._play(50, 0.8, 'sine', 0.3), 100);
|
||||
this._delay(() => this._play(50, 0.8, 'sine', 0.3), 100);
|
||||
},
|
||||
|
||||
crashCashout() {
|
||||
// Cashout success
|
||||
this._play(600, 0.1, 'sine', 0.3);
|
||||
setTimeout(() => this._play(800, 0.1, 'sine', 0.3), 80);
|
||||
setTimeout(() => this._play(1000, 0.2, 'sine', 0.4), 160);
|
||||
this._delay(() => this._play(800, 0.1, 'sine', 0.3), 80);
|
||||
this._delay(() => this._play(1000, 0.2, 'sine', 0.4), 160);
|
||||
},
|
||||
|
||||
click() {
|
||||
@@ -181,5 +221,4 @@ const SoundManager = {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-init
|
||||
document.addEventListener('DOMContentLoaded', () => SoundManager.init());
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
const SoundManager={_ctx:null,_enabled:true,_volume:0.3,_activeNodes:new Set(),_timeouts:[],init(){try{this._ctx=new(window.AudioContext||window.webkitAudioContext)();}catch(e){console.warn('Web Audio API not supported');this._enabled=false;}
|
||||
const saved=localStorage.getItem('sound_enabled');if(saved!==null)this._enabled=saved==='true';},setEnabled(on){this._enabled=on;localStorage.setItem('sound_enabled',on);},toggle(){this.setEnabled(!this._enabled);return this._enabled;},stopAll(){this._timeouts.forEach(clearTimeout);this._timeouts=[];this._activeNodes.forEach(node=>{try{node.stop();}catch(e){}
|
||||
try{node.disconnect();}catch(e){}});this._activeNodes.clear();},_trackNode(node,duration){this._activeNodes.add(node);node.addEventListener('ended',()=>this._activeNodes.delete(node));setTimeout(()=>{this._activeNodes.delete(node);try{node.disconnect();}catch(e){}},(duration+0.1)*1000);},_play(freq,duration,type='sine',volume=1){if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();const osc=this._ctx.createOscillator();const gain=this._ctx.createGain();osc.type=type;osc.frequency.setValueAtTime(freq,this._ctx.currentTime);gain.gain.setValueAtTime(this._volume*volume,this._ctx.currentTime);gain.gain.exponentialRampToValueAtTime(0.001,this._ctx.currentTime+duration);osc.connect(gain);gain.connect(this._ctx.destination);osc.start();osc.stop(this._ctx.currentTime+duration);this._trackNode(osc,duration);this._trackNode(gain,duration);},_noise(duration,volume=1){if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();const bufferSize=this._ctx.sampleRate*duration;const buffer=this._ctx.createBuffer(1,bufferSize,this._ctx.sampleRate);const data=buffer.getChannelData(0);for(let i=0;i<bufferSize;i++){data[i]=Math.random()*2-1;}
|
||||
const source=this._ctx.createBufferSource();source.buffer=buffer;const gain=this._ctx.createGain();gain.gain.setValueAtTime(this._volume*volume,this._ctx.currentTime);gain.gain.exponentialRampToValueAtTime(0.001,this._ctx.currentTime+duration);source.connect(gain);gain.connect(this._ctx.destination);source.start();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(){this._play(800,0.1,'square',0.3);this._delay(()=>this._play(400,0.15,'sawtooth',0.2),80);this._delay(()=>this._play(200,0.2,'sine',0.15),180);this._noise(0.05,0.4);},caseRare(){this._play(523,0.15,'sine',0.3);this._delay(()=>this._play(659,0.15,'sine',0.3),100);this._delay(()=>this._play(784,0.15,'sine',0.3),200);this._delay(()=>this._play(1047,0.3,'sine',0.4),300);},caseCovert(){this._play(392,0.2,'sawtooth',0.3);this._delay(()=>this._play(523,0.2,'sawtooth',0.3),150);this._delay(()=>this._play(659,0.2,'sawtooth',0.3),300);this._delay(()=>this._play(784,0.4,'sine',0.5),450);this._delay(()=>this._play(1047,0.6,'sine',0.6),600);},slotSpin(){for(let i=0;i<8;i++){this._delay(()=>{this._play(300+Math.random()*400,0.05,'square',0.15);},i*80);}},slotMatch(){this._play(523,0.1,'sine',0.3);this._delay(()=>this._play(659,0.1,'sine',0.3),100);this._delay(()=>this._play(784,0.1,'sine',0.3),200);this._delay(()=>this._play(1047,0.4,'sine',0.5),300);},contractSubmit(){this._play(150,0.3,'sawtooth',0.2);this._delay(()=>this._play(200,0.2,'square',0.2),200);this._delay(()=>this._play(250,0.1,'square',0.15),350);},contractResult(){this._play(400,0.15,'sine',0.3);this._delay(()=>this._play(600,0.15,'sine',0.3),120);this._delay(()=>this._play(800,0.3,'sine',0.4),240);},wheelSpin(){this._play(600+Math.random()*400,0.04,'triangle',0.12);this._play(200,0.02,'square',0.06);},wheelWin(){const t=this._ctx.currentTime;if(!this._enabled||!this._ctx)return;if(this._ctx.state==='suspended')this._ctx.resume();[523,659,784,1047].forEach((freq,i)=>{const osc=this._ctx.createOscillator();const gain=this._ctx.createGain();osc.type='sine';osc.frequency.setValueAtTime(freq,t+i*0.1);gain.gain.setValueAtTime(0,t+i*0.1);gain.gain.linearRampToValueAtTime(this._volume*0.35,t+i*0.1+0.05);gain.gain.exponentialRampToValueAtTime(0.001,t+i*0.1+0.5);osc.connect(gain);gain.connect(this._ctx.destination);osc.start(t+i*0.1);osc.stop(t+i*0.1+0.5);this._trackNode(osc,0.6);this._trackNode(gain,0.6);});this._delay(()=>this._play(1568,0.4,'sine',0.25),350);this._delay(()=>this._play(2093,0.6,'sine',0.2),500);},wheelLose(){this._play(300,0.15,'sawtooth',0.25);this._delay(()=>this._play(200,0.2,'sawtooth',0.2),100);this._delay(()=>this._play(120,0.3,'sawtooth',0.15),220);this._noise(0.15,0.3);this._delay(()=>{this._play(60,0.5,'sine',0.2);this._play(45,0.6,'sine',0.15);},350);},achievement(){this._play(659,0.1,'sine',0.3);this._delay(()=>this._play(523,0.1,'sine',0.3),100);this._delay(()=>this._play(784,0.1,'sine',0.3),200);this._delay(()=>this._play(659,0.1,'sine',0.3),300);this._delay(()=>this._play(1047,0.4,'sine',0.5),400);},crashBet(){this._play(300,0.1,'square',0.15);},crashTick(){this._play(500+Math.random()*500,0.03,'sine',0.08);},crashCrashed(){this._noise(0.3,0.6);this._play(80,0.5,'sawtooth',0.4);this._delay(()=>this._play(50,0.8,'sine',0.3),100);},crashCashout(){this._play(600,0.1,'sine',0.3);this._delay(()=>this._play(800,0.1,'sine',0.3),80);this._delay(()=>this._play(1000,0.2,'sine',0.4),160);},click(){this._play(800,0.03,'sine',0.1);},error(){this._play(200,0.15,'square',0.2);},balanceUpdate(){this._play(1000,0.05,'sine',0.1);}};document.addEventListener('DOMContentLoaded',()=>SoundManager.init());
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
const WS={_ws:null,_reconnectTimer:null,_listeners:{},_connected:false,connect(){if(this._ws&&this._ws.readyState===WebSocket.OPEN)return;const protocol=window.location.protocol==='https:'?'wss:':'ws:';const url=`${protocol}
|
||||
try{this._ws=new WebSocket(url);}catch(e){console.warn('WS connection failed, retrying in 5s');this._scheduleReconnect();return;}
|
||||
this._ws.onopen=()=>{this._connected=true;this._emit('connected');};this._ws.onclose=()=>{this._connected=false;this._emit('disconnected');this._scheduleReconnect();};this._ws.onerror=()=>{this._emit('error');};this._ws.onmessage=(event)=>{try{const data=JSON.parse(event.data);this._emit(data.type,data);}catch(e){}};this._pingInterval=setInterval(()=>{if(this._ws&&this._ws.readyState===WebSocket.OPEN){this._ws.send(JSON.stringify({type:'ping'}));}},30000);},disconnect(){if(this._pingInterval)clearInterval(this._pingInterval);if(this._reconnectTimer)clearTimeout(this._reconnectTimer);if(this._ws){this._ws.onclose=null;this._ws.close();this._ws=null;}
|
||||
this._connected=false;},_scheduleReconnect(){if(this._reconnectTimer)return;this._reconnectTimer=setTimeout(()=>{this._reconnectTimer=null;this.connect();},5000);},on(event,callback){if(!this._listeners[event])this._listeners[event]=[];this._listeners[event].push(callback);return()=>{this._listeners[event]=this._listeners[event].filter(cb=>cb!==callback);};},_emit(event,data){(this._listeners[event]||[]).forEach(cb=>cb(data));(this._listeners['*']||[]).forEach(cb=>cb(event,data));},isConnected(){return this._connected;}};document.addEventListener('DOMContentLoaded',()=>WS.connect());
|
||||
@@ -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">
|
||||
@@ -172,30 +219,30 @@
|
||||
</button>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const list = document.getElementById('activitySidebarList');
|
||||
const sidebar = document.getElementById('activitySidebar');
|
||||
document.body.classList.add('has-activity-sidebar');
|
||||
|
||||
// Восстанавливаем состояние из localStorage
|
||||
if (localStorage.getItem('sidebar_closed') === 'true') {
|
||||
sidebar.classList.add('closed');
|
||||
document.body.classList.add('sidebar-closed');
|
||||
document.getElementById('activityToggleBtn').classList.add('visible');
|
||||
}
|
||||
|
||||
WS.on('online_count', (data) => {
|
||||
document.getElementById('onlineCount').textContent = data.online || 0;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch('/web/api/activity?limit=10');
|
||||
const data = await res.json();
|
||||
if (data.success && data.activities) {
|
||||
renderActivities(data.activities);
|
||||
}
|
||||
} catch (e) {
|
||||
if (list) list.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary);">Не удалось загрузить</div>';
|
||||
function loadActivities() {
|
||||
fetch('/web/api/activity?limit=10')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.activities) {
|
||||
renderActivities(data.activities);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (list && !list.querySelector('.activity-sidebar-item')) {
|
||||
list.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary);font-size:0.85rem;">Не удалось загрузить</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
WS.on('activity', (data) => {
|
||||
@@ -210,9 +257,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
list.removeChild(list.lastChild);
|
||||
}
|
||||
});
|
||||
|
||||
WS.on('connected', loadActivities);
|
||||
|
||||
loadActivities();
|
||||
});
|
||||
|
||||
const RARITY_COLORS = {
|
||||
const ACTIVITY_RARITY_COLORS = {
|
||||
'consumer-grade': '#b0b0b0', 'industrial-grade': '#5e98d9',
|
||||
'mil-spec': '#4b69ff', 'mil-spec-grade': '#4b69ff',
|
||||
'restricted': '#8847ff', 'classified': '#d32ce6',
|
||||
@@ -231,9 +282,33 @@ function createActivityItem(act) {
|
||||
item.style.cursor = 'pointer';
|
||||
|
||||
const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, '');
|
||||
const rarityColor = RARITY_COLORS[raritySlug] || '';
|
||||
if (rarityColor) {
|
||||
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
|
||||
const rarityColor = ACTIVITY_RARITY_COLORS[raritySlug] || '';
|
||||
|
||||
// ── Эффект ──
|
||||
const effect = act.data && act.data.effect;
|
||||
let effectBadgeHtml = '';
|
||||
if (effect) {
|
||||
const isUpgrade = effect.startsWith('upgrade:');
|
||||
const isDrop = effect.startsWith('drop:');
|
||||
const label = effect.replace('upgrade:', '').replace('drop:', '');
|
||||
const cls = isUpgrade ? 'upgrade' : 'drop';
|
||||
effectBadgeHtml = `<span class="asi-effect-badge ${cls}">${label}</span>`;
|
||||
item.classList.add('asi-effect');
|
||||
item.dataset.effectText = label;
|
||||
if (isDrop) {
|
||||
item.classList.add('asi-effect-glow');
|
||||
const sh = document.createElement('div');
|
||||
sh.className = 'asi-effect-shimmer';
|
||||
item.appendChild(sh);
|
||||
}
|
||||
if (rarityColor) {
|
||||
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.12)`;
|
||||
item.style.borderLeft = `3px solid ${rarityColor}`;
|
||||
}
|
||||
} else {
|
||||
if (rarityColor) {
|
||||
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
|
||||
}
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => window.location.href = `/profiles/${act.user_id}`);
|
||||
@@ -251,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}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link{% if active_page == 'cases' %} active{% endif %}">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link{% if active_page == 'contracts' %} active{% endif %}">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link{% if active_page == 'upgrade' %} active{% endif %}">🆙 Апгрейд</a>
|
||||
{% if user %}
|
||||
<a href="/crash" class="nav-link{% if active_page == 'crash' %} active{% endif %}">💥 Crash</a>
|
||||
<a href="/profile" class="nav-link{% if active_page == 'profile' %} active{% endif %}">Профиль</a>
|
||||
<a href="/activity" class="nav-link{% if active_page == 'activity' %} active{% endif %}">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link{% if active_page == 'achievements' %} active{% endif %}">🏆 Достижения</a>
|
||||
<span class="user-balance">{{ "{:,.0f}".format(user.balance).replace(',', ' ') }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/activity" class="nav-link{% if active_page == 'activity' %} active{% endif %}">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
+42
-28
@@ -4,8 +4,32 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
<style>
|
||||
.achievement-card.secret {
|
||||
border-color: #8b5cf6;
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.05), transparent);
|
||||
}
|
||||
.achievement-card.secret.unlocked {
|
||||
border-color: #8b5cf6;
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.12), transparent);
|
||||
}
|
||||
.achievement-card.secret .achievement-icon {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
.achievement-card.secret.unlocked .achievement-icon {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
.achievement-card .secret-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 0.65rem;
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #a78bfa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.achievements-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -183,25 +207,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/achievements" class="nav-link active">🏆 Достижения</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="container" style="padding: 2rem 1rem;">
|
||||
<div class="achievements-header">
|
||||
@@ -236,19 +242,25 @@
|
||||
|
||||
<div class="achievements-grid" id="achievementsGrid">
|
||||
{% for ach in achievements %}
|
||||
<div class="achievement-card {{ 'unlocked' if ach.unlocked else 'locked' }}"
|
||||
<div class="achievement-card {{ 'unlocked' if ach.unlocked else 'locked' }} {{ 'secret' if ach.hidden else '' }}"
|
||||
data-category="{{ ach.category }}">
|
||||
{% if ach.hidden and not ach.unlocked %}
|
||||
<div class="achievement-icon">❓</div>
|
||||
<div class="achievement-info">
|
||||
<div class="achievement-title">???</div>
|
||||
<div class="achievement-desc">Хммм... похоже что я не знаю как его открыть</div>
|
||||
<div class="secret-badge">🔒 Секрет</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="achievement-icon">{{ ach.icon }}</div>
|
||||
<div class="achievement-info">
|
||||
{% 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">+{{ "%.0f"|format(ach.reward_amount) }} ₽</div>
|
||||
{% endif %}
|
||||
<div class="achievement-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill {{ 'complete' if ach.unlocked else '' }}"
|
||||
style="width: {{ (ach.progress / ach.requirement_value * 100)|round }}%"></div>
|
||||
style="width: {{ (ach.progress / ach.requirement_value * 100)|round if ach.requirement_value > 0 else 100 }}%"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
{% if ach.unlocked %}
|
||||
@@ -259,6 +271,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -270,8 +283,9 @@
|
||||
<div class="popup-reward" id="popupReward"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -291,7 +305,7 @@
|
||||
function showAchievementPopup(title, reward) {
|
||||
const popup = document.getElementById('achievementPopup');
|
||||
document.getElementById('popupTitle').textContent = title;
|
||||
document.getElementById('popupReward').textContent = reward ? `+${reward} ₽` : '';
|
||||
document.getElementById('popupReward').textContent = '';
|
||||
popup.style.display = 'block';
|
||||
SoundManager.achievement();
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,245 +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>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link active">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link active">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container" style="padding: 2rem 1rem;">
|
||||
<div class="activity-header">
|
||||
<div>
|
||||
<h1>📰 Лента активностей</h1>
|
||||
<p class="page-subtitle">Что происходит на сервере</p>
|
||||
</div>
|
||||
<div class="live-indicator" id="liveIndicator">
|
||||
<span class="live-dot"></span>
|
||||
<span>Live</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activity-feed" id="activityFeed">
|
||||
{% if activities %}
|
||||
{% for a in activities %}
|
||||
<div class="activity-item" data-activity-id="{{ a.id }}">
|
||||
<div class="activity-icon">
|
||||
{% if a.activity_type == 'case_open' %}📦
|
||||
{% elif a.activity_type == 'contract' %}🔄
|
||||
{% elif a.activity_type == 'upgrade' %}🆙
|
||||
{% elif a.activity_type == 'achievement' %}🏆
|
||||
{% elif a.activity_type == 'crash' %}💥
|
||||
{% else %}📌{% endif %}
|
||||
</div>
|
||||
<div class="activity-content">
|
||||
<div class="activity-message">{{ a.message }}</div>
|
||||
<div class="activity-meta">
|
||||
<a href="/profiles/{{ a.user_id }}" class="activity-username" style="text-decoration:none;">{{ a.username }}</a>
|
||||
<span>• {{ a.created_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="activity-empty">
|
||||
<div class="big-icon">📭</div>
|
||||
<h3>Пока нет активностей</h3>
|
||||
<p>Открой кейс или выполни контракт — это появится здесь в реальном времени!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Подключаемся к WS и слушаем новые активности
|
||||
WS.on('activity', (data) => {
|
||||
const act = data.activity;
|
||||
if (!act) return;
|
||||
|
||||
const feed = document.getElementById('activityFeed');
|
||||
|
||||
// Убираем empty state если есть
|
||||
const empty = feed.querySelector('.activity-empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
const iconMap = {
|
||||
'case_open': '📦',
|
||||
'contract': '🔄',
|
||||
'upgrade': '🆙',
|
||||
'achievement': '🏆',
|
||||
'crash': '💥'
|
||||
};
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'activity-item';
|
||||
item.style.animation = 'none';
|
||||
item.offsetHeight;
|
||||
item.style.animation = 'fadeInUp 0.3s ease';
|
||||
item.innerHTML = `
|
||||
<div class="activity-icon">${iconMap[act.activity_type] || '📌'}</div>
|
||||
<div class="activity-content">
|
||||
<div class="activity-message">${act.message}</div>
|
||||
<div class="activity-meta">
|
||||
<a href="/profiles/${act.user_id || 0}" class="activity-username" style="text-decoration:none;">${escapeHtml(act.username)}</a>
|
||||
<span>• только что</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
feed.insertBefore(item, feed.firstChild);
|
||||
|
||||
// Ограничиваем до 100 элементов
|
||||
while (feed.children.length > 100) {
|
||||
feed.removeChild(feed.lastChild);
|
||||
}
|
||||
|
||||
// Звук для новых активностей
|
||||
if (act.activity_type === 'achievement') {
|
||||
SoundManager.achievement();
|
||||
} else if (act.activity_type === 'crash') {
|
||||
SoundManager.crashCashout();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
function toggleSound() {
|
||||
const enabled = SoundManager.toggle();
|
||||
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('soundToggle');
|
||||
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+154
-11
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Админ-панель{% endblock %} — CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 15px; }
|
||||
@@ -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">
|
||||
@@ -356,6 +470,35 @@
|
||||
</div>
|
||||
|
||||
{% block modals %}{% endblock %}
|
||||
<script src="/static/js/notifications.min.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>
|
||||
|
||||
+296
-123
@@ -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,36 +337,110 @@ function removeCaseItem(idx) {
|
||||
}
|
||||
|
||||
function addCaseItem(item) {
|
||||
if (currentCaseItems.some(i => (i.market_hash_name || i.name) === (item.market_hash_name || item.name))) return;
|
||||
const key = (item.market_hash_name || item.name || '');
|
||||
if (currentCaseItems.some(i => (i.market_hash_name || i.name) === key)) return;
|
||||
currentCaseItems.push(item);
|
||||
renderCaseItems();
|
||||
}
|
||||
|
||||
function addAllFromCollection() {
|
||||
const sel = document.getElementById('collectionFilter');
|
||||
const coll = sel.value;
|
||||
if (!coll) { Notify.error('Выберите коллекцию'); return; }
|
||||
const rarity = document.getElementById('rarityFilter').value;
|
||||
const typeF = document.getElementById('typeFilter').value;
|
||||
const weaponF = document.getElementById('weaponFilter').value;
|
||||
let url = `/admin/api/items/by-collection?collection=${encodeURIComponent(coll)}`;
|
||||
if (rarity) url += `&rarity_filter=${encodeURIComponent(rarity)}`;
|
||||
if (typeF) url += `&type_filter=${encodeURIComponent(typeF)}`;
|
||||
if (weaponF) url += `&weapon_filter=${encodeURIComponent(weaponF)}`;
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(items => {
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
const alreadyIds = new Set(currentCaseItems.map(i => i.id));
|
||||
items.forEach(item => {
|
||||
if (alreadyIds.has(item.id)) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
const key = (item.name || '');
|
||||
if (!currentCaseItems.some(i => (i.market_hash_name || i.name) === key)) {
|
||||
currentCaseItems.push(item);
|
||||
added++;
|
||||
}
|
||||
});
|
||||
renderCaseItems();
|
||||
let msg = `Добавлено ${added} предметов из "${coll}"`;
|
||||
if (skipped) msg += ` (${skipped} уже были в кейсе)`;
|
||||
Notify.success(msg);
|
||||
});
|
||||
}
|
||||
|
||||
async function searchCaseItems() {
|
||||
const q = document.getElementById('caseItemSearch').value;
|
||||
if (q.length < 2) return;
|
||||
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}`);
|
||||
const items = await r.json();
|
||||
// show as dropdown
|
||||
let html = '<div style="margin-top:0.5rem;max-height:200px;overflow-y:auto">';
|
||||
items.forEach(item => {
|
||||
html += `<div class="ci-item" style="cursor:pointer" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'"')})">
|
||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||
<div class="ci-info"><div>${item.name}</div><div class="ci-rarity">${item.rarity} • ${item.price_rub||0} ₽</div></div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
const existing = document.getElementById('caseItemSearchResults');
|
||||
if (!existing) {
|
||||
const d = document.createElement('div'); d.id = 'caseItemSearchResults';
|
||||
document.getElementById('caseItemSearch').parentNode.appendChild(d);
|
||||
}
|
||||
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,'"')})">
|
||||
<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() {
|
||||
const name = document.getElementById('caseNameInput').value.trim();
|
||||
if (!name) { alert('Введите название кейса'); return; }
|
||||
if (!name) { Notify.error('Введите название кейса'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('case_name', name);
|
||||
fd.append('display_name', document.getElementById('caseDisplayName').value);
|
||||
@@ -263,47 +449,30 @@ 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(); }
|
||||
else alert(d.error || 'Ошибка');
|
||||
else Notify.error(d.error || 'Ошибка');
|
||||
}
|
||||
|
||||
async function deleteCase(name) {
|
||||
if (!confirm(`Удалить кейс "${name}"?`)) return;
|
||||
const fd = new FormData(); fd.append('case_name', name);
|
||||
const r = await fetch(`/admin/api/cases/delete/${encodeURIComponent(name)}`, { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
}
|
||||
|
||||
// ─── All Items Browser ───────────────────────────────────────────────────────
|
||||
async function openAllItemsModal() {
|
||||
openModal('allItemsModal');
|
||||
document.getElementById('allItemsSearch').value = '';
|
||||
await searchAllItems();
|
||||
}
|
||||
async function searchAllItems() {
|
||||
const q = document.getElementById('allItemsSearch').value;
|
||||
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}&limit=200`);
|
||||
const items = await r.json();
|
||||
document.getElementById('allItemsGrid').innerHTML = items.map(item => `
|
||||
<div class="all-item-card" onclick="addCaseItemFromAll(${JSON.stringify(item).replace(/"/g,'"')})">
|
||||
<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');
|
||||
Notify.confirm(`Удалить кейс "${name}"?`, 'Подтверждение', async function(ok) {
|
||||
if (!ok) return;
|
||||
const fd = new FormData(); fd.append('case_name', name);
|
||||
const r = await fetch(`/admin/api/cases/delete/${encodeURIComponent(name)}`, { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else Notify.error(d.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Mainpage Sections ───────────────────────────────────────────────────────
|
||||
@@ -318,21 +487,23 @@ async function confirmAddSection() {
|
||||
const r = await fetch('/admin/api/mainpage/section/add', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
else Notify.error(d.error);
|
||||
}
|
||||
async function deleteSection(tab) {
|
||||
if (!confirm(`Удалить секцию "${tab}"?`)) return;
|
||||
const fd = new FormData(); fd.append('tab', tab);
|
||||
const r = await fetch('/admin/api/mainpage/section/delete', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
Notify.confirm(`Удалить секцию "${tab}"?`, 'Подтверждение', async function(ok) {
|
||||
if (!ok) return;
|
||||
const fd = new FormData(); fd.append('tab', tab);
|
||||
const r = await fetch('/admin/api/mainpage/section/delete', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else Notify.error(d.error);
|
||||
});
|
||||
}
|
||||
|
||||
function openAddCaseModal(tab) {
|
||||
document.getElementById('addCaseSectionName').textContent = tab;
|
||||
const sel = document.getElementById('addCaseSelect');
|
||||
sel.innerHTML = allCasesList.map(c => `<option value="${c}">${c}</option>`).join('');
|
||||
sel.innerHTML = allCasesList.map(c => `<option value="${c.name}">${c.display_name || c.name}</option>`).join('');
|
||||
sel.dataset.tab = tab;
|
||||
openModal('addCaseModal');
|
||||
}
|
||||
@@ -345,17 +516,19 @@ async function confirmAddCase() {
|
||||
const r = await fetch('/admin/api/mainpage/section/add-case', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
else Notify.error(d.error);
|
||||
}
|
||||
async function removeCaseFromSection(tab, caseName) {
|
||||
if (!confirm(`Убрать "${caseName}" из "${tab}"?`)) return;
|
||||
const fd = new FormData();
|
||||
fd.append('tab', tab);
|
||||
fd.append('case_name', caseName);
|
||||
const r = await fetch('/admin/api/mainpage/section/remove-case', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
Notify.confirm(`Убрать "${caseName}" из "${tab}"?`, 'Подтверждение', async function(ok) {
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append('tab', tab);
|
||||
fd.append('case_name', caseName);
|
||||
const r = await fetch('/admin/api/mainpage/section/remove-case', { method:'POST', body:fd });
|
||||
const d = await r.json();
|
||||
if (d.success) window.location.reload();
|
||||
else Notify.error(d.error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
@@ -27,7 +34,7 @@
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Общий баланс</div>
|
||||
<div class="as-value">{{ "%.0f"|format(total_balance) }} ₽</div>
|
||||
<div class="as-value">{{ total_balance|money }} ₽</div>
|
||||
<div class="as-bar" style="width:40%;background:#34d399"></div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
@@ -47,7 +54,7 @@
|
||||
<div><div class="as-value" style="font-size:1.2rem" style="color:#22c55e">{{ lucky_count }}</div><div class="as-label" style="font-size:0.7rem">🍀 Везучих</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem" style="color:#f59e0b">{{ normal_count }}</div><div class="as-label" style="font-size:0.7rem">📊 Обычных</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem" style="color:#ef4444">{{ unlucky_count }}</div><div class="as-label" style="font-size:0.7rem">🌧️ Невезучих</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem">{{ "%.0f"|format(total_spent) }} ₽</div><div class="as-label" style="font-size:0.7rem">Потрачено всего</div></div>
|
||||
<div><div class="as-value" style="font-size:1.2rem">{{ total_spent|money }} ₽</div><div class="as-label" style="font-size:0.7rem">Потрачено всего</div></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="a-empty"><div class="a-empty-icon">📭</div>Нет данных RPU</div>
|
||||
@@ -55,3 +62,23 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function loadPromoPhase() {
|
||||
try {
|
||||
const r = await fetch('/admin/api/promo-phase');
|
||||
const d = await r.json();
|
||||
const el = document.getElementById('promoPhaseInfo');
|
||||
if (d.current_phase === 'LUCKY') {
|
||||
el.innerHTML = '<span style="color:#22c55e">🍀 LUCKY</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||
} else if (d.current_phase === 'UNLUCKY') {
|
||||
el.innerHTML = '<span style="color:#ef4444">💀 UNLUCKY</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||
} else {
|
||||
el.innerHTML = '<span style="color:#a3a3a3">⚪ NEUTRAL</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', loadPromoPhase);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
{% endblock %}
|
||||
{% block top_actions %}
|
||||
<a href="/admin/users" class="at-back">← Назад</a>
|
||||
<a href="/admin/user/{{ target_user.id }}/history" class="a-btn" style="margin-left:0.5rem;font-size:0.82rem;">📜 История</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-stats" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="a-stats">
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Баланс</div>
|
||||
<div class="as-value">{{ "%.0f"|format(target_user.balance) }} ₽</div>
|
||||
<div class="as-value">{{ target_user.balance|money }} ₽</div>
|
||||
</div>
|
||||
<div class="a-stat">
|
||||
<div class="as-label">Предметов</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 %}
|
||||
@@ -94,7 +136,7 @@
|
||||
<div class="a-modal">
|
||||
<div class="a-modal-title">💰 Изменить баланс</div>
|
||||
<p style="font-size:0.85rem;color:rgba(255,255,255,0.5);margin-bottom:1rem">
|
||||
Текущий: <strong style="color:#f59e0b">{{ "%.2f"|format(target_user.balance) }} ₽</strong>
|
||||
Текущий: <strong style="color:#f59e0b">{{ target_user.balance|money }} ₽</strong>
|
||||
</p>
|
||||
<div class="a-mb">
|
||||
<label class="a-label">Сумма</label>
|
||||
@@ -180,8 +222,8 @@ async function updateBalance() {
|
||||
fd.append('operation', document.getElementById('balanceOperation').value);
|
||||
const res = await fetch(`/admin/api/user/${userId}/balance`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); window.location.reload(); }
|
||||
else alert(d.error);
|
||||
if (d.success) { Notify.success(d.message); window.location.reload(); }
|
||||
else Notify.error(d.error);
|
||||
}
|
||||
|
||||
// ====== GIVE ITEM ======
|
||||
@@ -205,31 +247,37 @@ async function giveItem(itemId) {
|
||||
const fd = new FormData(); fd.append('item_id', itemId);
|
||||
const res = await fetch(`/admin/api/user/${userId}/give-item`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); closeModal('giveItemModal'); window.location.reload(); }
|
||||
else alert(d.error);
|
||||
if (d.success) { Notify.success(d.message); closeModal('giveItemModal'); window.location.reload(); }
|
||||
else Notify.error(d.error);
|
||||
}
|
||||
|
||||
// ====== DELETE ITEM ======
|
||||
async function deleteItem(invId) {
|
||||
if (!confirm('Удалить предмет?')) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/delete-item/${invId}`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload();
|
||||
else alert(d.error);
|
||||
Notify.confirm('Удалить предмет?', 'Подтверждение', async function(ok) {
|
||||
if (!ok) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/delete-item/${invId}`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload();
|
||||
else Notify.error(d.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ====== TOGGLE ADMIN / BAN ======
|
||||
async function toggleAdmin() {
|
||||
if (!confirm('Изменить статус админа?')) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/toggle-admin`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload(); else alert(d.error);
|
||||
Notify.confirm('Изменить статус админа?', 'Подтверждение', async function(ok) {
|
||||
if (!ok) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/toggle-admin`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload(); else Notify.error(d.error);
|
||||
});
|
||||
}
|
||||
async function toggleBan() {
|
||||
if (!confirm('{{ "Забанить" if not target_user.is_banned else "Разбанить" }}?')) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/toggle-ban`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload(); else alert(d.error);
|
||||
Notify.confirm('{{ "Забанить" if not target_user.is_banned else "Разбанить" }}?', 'Подтверждение', async function(ok) {
|
||||
if (!ok) return;
|
||||
const res = await fetch(`/admin/api/user/${userId}/toggle-ban`, { method:'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) window.location.reload(); else Notify.error(d.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ====== RPU ======
|
||||
@@ -245,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(_) {}
|
||||
}
|
||||
|
||||
@@ -277,16 +343,18 @@ async function saveRPU() {
|
||||
fd.append('auto_adjust', document.getElementById('rpuAutoAdjust').checked);
|
||||
const res = await fetch(`/admin/api/user/${userId}/rpu/update`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); closeModal('rpuModal'); loadRPU(); }
|
||||
else alert(d.error);
|
||||
if (d.success) { Notify.success(d.message); closeModal('rpuModal'); loadRPU(); }
|
||||
else Notify.error(d.error);
|
||||
}
|
||||
async function setRPUPreset(preset) {
|
||||
const names = { lucky:'🍀 Везучий', normal:'📊 Обычный', unlucky:'🌧️ Невезучий', very_unlucky:'💀 Проклятый' };
|
||||
if (!confirm('Применить "'+names[preset]+'"?')) return;
|
||||
const fd = new FormData(); fd.append('preset', preset);
|
||||
const res = await fetch(`/admin/api/user/${userId}/rpu/preset`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert(d.message); loadRPU(); } else alert(d.error);
|
||||
Notify.confirm('Применить "'+names[preset]+'"?', 'Подтверждение', async function(ok) {
|
||||
if (!ok) return;
|
||||
const fd = new FormData(); fd.append('preset', preset);
|
||||
const res = await fetch(`/admin/api/user/${userId}/rpu/preset`, { method:'POST', body:fd });
|
||||
const d = await res.json();
|
||||
if (d.success) { Notify.success(d.message); loadRPU(); } else Notify.error(d.error);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadRPU);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}История {{ target_user.username }} — Админ-панель{% endblock %}
|
||||
{% block nav_users %}active{% endblock %}
|
||||
{% block page_title %}
|
||||
📜 История {{ target_user.username }}
|
||||
<span class="at-sub">ID {{ target_user.id }}</span>
|
||||
{% endblock %}
|
||||
{% block top_actions %}
|
||||
<a href="/admin/user/{{ target_user.id }}" class="at-back">← Профиль</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.rarity-Covert, .rarity-Rare\ Special\ Item, .rarity-Extraordinary { color: #eb4b4b; }
|
||||
.rarity-Classified { color: #d32ce6; }
|
||||
.rarity-Restricted { color: #8847ff; }
|
||||
.rarity-Mil-Spec, .rarity-Mil-Spec\ Grade { color: #4b69ff; }
|
||||
.rarity-Industrial\ Grade { color: #5e98d9; }
|
||||
.rarity-Consumer\ Grade { color: #b0b0b0; }
|
||||
.mono { font-family: monospace; font-size: 0.72rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="a-stats" style="margin-bottom:1rem">
|
||||
<div class="a-stat"><div class="as-label">Потолок</div><div class="as-value">{{ "%.0f"|format(ceiling) }} ₽</div></div>
|
||||
<div class="a-stat"><div class="as-label">Текущая ценность</div><div class="as-value">{{ "%.0f"|format(total_value) }} ₽</div></div>
|
||||
<div class="a-stat"><div class="as-label">Luck budget</div><div class="as-value">{{ "%.1f"|format(rpu.luck_budget) }}%</div></div>
|
||||
<div class="a-stat"><div class="as-label">Hot score</div><div class="as-value">{{ "%.0f"|format(rpu.hot_score or 0) }}</div></div>
|
||||
<div class="a-stat"><div class="as-label">Ceiling mult</div><div class="as-value">{{ "%.2f"|format(rpu.ceiling_multiplier) }}</div></div>
|
||||
<div class="a-stat"><div class="as-label">Комбек</div><div class="as-value">{{ rpu.comeback_active }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="a-flex a-mb" style="gap:0.5rem">
|
||||
<button class="a-btn a-btn-primary tab-btn active" onclick="switchTab('openings')">📦 Открытия ({{ openings|length }})</button>
|
||||
<button class="a-btn tab-btn" onclick="switchTab('upgrades')">⬆️ Апгрейды ({{ upgrades|length }})</button>
|
||||
<button class="a-btn tab-btn" onclick="switchTab('transactions')">💳 Транзакции ({{ transactions|length }})</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-openings" class="tab-content" style="display:block">
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>ID</th><th>Дата</th><th>Кейс</th><th>Предмет</th><th>Редкость</th><th>Float</th></tr></thead>
|
||||
<tbody>
|
||||
{% for o in openings %}
|
||||
<tr>
|
||||
<td class="mono">{{ o.id }}</td>
|
||||
<td>{{ o.opened_at.strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ o.case_name }}</td>
|
||||
<td>{{ o.item_name }}</td>
|
||||
<td><span class="rarity-{{ o.rarity|replace(' ', '\\ ') }}">{{ o.rarity }}</span></td>
|
||||
<td>{{ "%.4f"|format(o.float_value) if o.float_value else '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="a-empty" style="padding:2rem">Нет открытий</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-upgrades" class="tab-content" style="display:none">
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>ID</th><th>Дата</th><th>Вход</th><th>Цель</th><th>Результат</th><th>Базовый шанс</th><th>RPU шанс</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in upgrades %}
|
||||
<tr>
|
||||
<td class="mono">{{ u.id }}</td>
|
||||
<td>{{ u.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ u.input_item_name }}</td>
|
||||
<td>{{ u.target_item_name }}</td>
|
||||
<td style="color:{% if u.success %}#22c55e{% else %}#ef4444{% endif %}">{% if u.success %}✅ Успех{% else %}❌ Провал{% endif %}</td>
|
||||
<td>{{ "%.1f"|format(u.probability * 100 if u.probability else 0) }}%</td>
|
||||
<td>{{ "%.1f"|format(u.rpu_adjusted_probability * 100 if u.rpu_adjusted_probability else 0) }}%</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="a-empty" style="padding:2rem">Нет апгрейдов</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-transactions" class="tab-content" style="display:none">
|
||||
<div class="a-table-wrap">
|
||||
<table class="a-table">
|
||||
<thead><tr><th>ID</th><th>Дата</th><th>Тип</th><th>Сумма</th><th>Комиссия</th><th>Детали</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in transactions %}
|
||||
<tr>
|
||||
<td class="mono">{{ t.id }}</td>
|
||||
<td>{{ t.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% if t.tx_type == 'card_to_site' %}<span class="a-badge a-badge-success">Депозит</span>
|
||||
{% elif t.tx_type == 'site_to_card' %}<span class="a-badge a-badge-banned">Вывод</span>
|
||||
{% elif t.tx_type == 'promo_bonus' %}<span class="a-badge" style="background:rgba(234,179,8,0.12);color:#eab308">Промо</span>
|
||||
{% else %}<span class="a-badge a-badge-user">{{ t.tx_type }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ "%.0f"|format(t.amount) }} ₽</td>
|
||||
<td>{% if t.fee %}{{ "%.0f"|format(t.fee) }} ₽{% else %}—{% endif %}</td>
|
||||
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ t.details or '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="a-empty" style="padding:2rem">Нет транзакций</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
|
||||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).style.display = 'block';
|
||||
document.querySelector('.tab-btn[onclick="switchTab(\'' + name + '\')"]').classList.add('active');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,8 +3,8 @@
|
||||
{% block nav_users %}active{% endblock %}
|
||||
{% block page_title %}👥 Пользователи{% endblock %}
|
||||
{% block top_actions %}
|
||||
<div class="a-flex">
|
||||
<input type="text" id="userSearch" class="a-input a-input-sm" style="width:200px" placeholder="Поиск по имени..." value="{{ search or '' }}">
|
||||
<div class="search-form a-flex">
|
||||
<input type="text" id="userSearch" class="a-input a-input-sm" placeholder="Поиск по имени..." value="{{ search or '' }}">
|
||||
<button class="a-btn a-btn-primary" onclick="searchUsers()">🔍 Найти</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -21,7 +21,7 @@
|
||||
<th>Статус</th>
|
||||
<th>Кейсов</th>
|
||||
<th>Контрактов</th>
|
||||
<th>Достижений</th>
|
||||
<th>Предметов</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -34,15 +34,15 @@
|
||||
{{ u.username }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ "%.0f"|format(u.balance) }} ₽</td>
|
||||
<td>{{ u.balance|money }} ₽</td>
|
||||
<td>
|
||||
{% if u.is_admin %}<span class="a-badge a-badge-admin">Админ</span>{% endif %}
|
||||
{% if u.is_banned %}<span class="a-badge a-badge-banned">Забанен</span>{% endif %}
|
||||
{% if not u.is_admin and not u.is_banned %}<span class="a-badge a-badge-user">Игрок</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ u.case_openings|default(0) }}</td>
|
||||
<td>{{ u.contracts|default(0) }}</td>
|
||||
<td>{{ u.achievements|default(0) }}</td>
|
||||
<td>{{ u.case_openings }}</td>
|
||||
<td>{{ u.contracts }}</td>
|
||||
<td>{{ u.inventory_count }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/admin/user/{{ u.id }}" class="a-btn a-btn-sm">✏️</a>
|
||||
</td>
|
||||
@@ -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">
|
||||
|
||||
+164
-165
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover">
|
||||
<title>{{ case_display_name }} - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
<style>
|
||||
/* Стили для карточек (только для фарм-кейсов) */
|
||||
.cards-grid {
|
||||
@@ -214,35 +214,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link active">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link active">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="case-detail-container">
|
||||
<div class="container">
|
||||
@@ -255,7 +227,7 @@
|
||||
<p class="case-subtitle">{{ total_items }} предметов в кейсе</p>
|
||||
</div>
|
||||
<div class="case-info-badge">
|
||||
<span class="case-price-tag">🔑 {{ "%.0f"|format(case_price) }} ₽ за шт.</span>
|
||||
<span class="case-price-tag">🔑 {{ case_price|money }} ₽ за шт.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +238,7 @@
|
||||
<div class="count-buttons" id="countButtons"></div>
|
||||
</div>
|
||||
<div class="total-price-display" id="totalPriceDisplay">
|
||||
<span>Итого:</span> {{ "%.0f"|format(case_price) }} ₽
|
||||
<span>Итого:</span> {{ case_price|money }} ₽
|
||||
</div>
|
||||
<button class="btn btn-primary btn-large" onclick="startOpening()" id="openButton">
|
||||
🎲 Открыть
|
||||
@@ -389,9 +361,9 @@
|
||||
{% if item.min_price and item.max_price %}
|
||||
<div class="item-price-compact">
|
||||
{% if item.min_price == item.max_price %}
|
||||
{{ "%.0f"|format(item.min_price) }} ₽
|
||||
{{ item.min_price|money }} ₽
|
||||
{% else %}
|
||||
{{ "%.0f"|format(item.min_price) }} - {{ "%.0f"|format(item.max_price) }} ₽
|
||||
{{ item.min_price|money }} - {{ item.max_price|money }} ₽
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -547,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) {
|
||||
@@ -603,7 +576,10 @@
|
||||
const hasFreeSpin = freeSpinActive;
|
||||
freeSpinActive = false;
|
||||
if (!hasFreeSpin && userBalance < totalPrice) {
|
||||
alert(`Недостаточно средств!\nНужно: ${totalPrice.toLocaleString()} ₽\nУ вас: ${Math.floor(userBalance).toLocaleString()} ₽`);
|
||||
Notify.error(`Недостаточно средств! Нужно: ${totalPrice.toLocaleString()} ₽, у вас: ${Math.floor(userBalance).toLocaleString()} ₽`);
|
||||
isOpening = false;
|
||||
openButton.disabled = false;
|
||||
openButton.textContent = isSlotCase ? '🎰 Крутить' : '🎲 Открыть';
|
||||
return;
|
||||
}
|
||||
isOpening = true;
|
||||
@@ -658,7 +634,7 @@
|
||||
if (data.new_balance !== undefined) userBalance = data.new_balance;
|
||||
await refreshUserBalance();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при открытии');
|
||||
Notify.error(data.error || 'Ошибка при открытии');
|
||||
}
|
||||
} else if (isFarmCase) {
|
||||
// ===== ФАРМ-КЕЙС: КАРТОЧКИ С АВТОПРОДАЖЕЙ ШЛАКА =====
|
||||
@@ -697,7 +673,7 @@
|
||||
}
|
||||
await refreshUserBalance();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при открытии');
|
||||
Notify.error(data.error || 'Ошибка при открытии');
|
||||
}
|
||||
} else if (fast) {
|
||||
// ===== БЫСТРОЕ ОТКРЫТИЕ БЕЗ АНИМАЦИИ =====
|
||||
@@ -720,7 +696,7 @@
|
||||
if (data.new_balance !== undefined) userBalance = data.new_balance;
|
||||
await refreshUserBalance();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при открытии');
|
||||
Notify.error(data.error || 'Ошибка при открытии');
|
||||
}
|
||||
} else {
|
||||
// ===== ОБЫЧНЫЙ КЕЙС: СКРОЛЛЫ (РУЛЕТКА) =====
|
||||
@@ -763,7 +739,7 @@
|
||||
})();
|
||||
|
||||
if (!batchData.success) {
|
||||
alert(batchData.error || 'Ошибка при открытии');
|
||||
Notify.error(batchData.error || 'Ошибка при открытии');
|
||||
} else {
|
||||
handleAchievements(batchData);
|
||||
SoundManager.caseOpen();
|
||||
@@ -797,7 +773,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error opening cases:', error);
|
||||
alert('Ошибка при открытии кейсов');
|
||||
Notify.error('Ошибка при открытии кейсов');
|
||||
} finally {
|
||||
isOpening = false;
|
||||
openButton.disabled = false;
|
||||
@@ -1116,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);
|
||||
}
|
||||
@@ -1126,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);
|
||||
@@ -1338,43 +1332,44 @@
|
||||
|
||||
async function sellAllTopDrops() {
|
||||
if (topDropItems.length === 0) {
|
||||
alert('Нет предметов для продажи');
|
||||
Notify.error('Нет предметов для продажи');
|
||||
return;
|
||||
}
|
||||
|
||||
const total = topDropItems.reduce((sum, i) => sum + (i.price || 100), 0);
|
||||
if (!confirm(`Продать все топ-дропы за ${total.toLocaleString()} ₽?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/items');
|
||||
const items = await response.json();
|
||||
|
||||
const inventoryIds = [];
|
||||
for (const item of topDropItems) {
|
||||
const invItem = items.find(i => i.item_id === item.id &&
|
||||
Math.abs(i.float - item.float) < 0.001);
|
||||
if (invItem) {
|
||||
inventoryIds.push(invItem.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (inventoryIds.length > 0) {
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(inventoryIds));
|
||||
Notify.confirm('Продать все топ-дропы за ' + total.toLocaleString() + ' ₽?', 'Продажа', async function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/items');
|
||||
const items = await response.json();
|
||||
|
||||
await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const inventoryIds = [];
|
||||
for (const item of topDropItems) {
|
||||
const invItem = items.find(i => i.item_id === item.id &&
|
||||
Math.abs(i.float - item.float) < 0.001);
|
||||
if (invItem) {
|
||||
inventoryIds.push(invItem.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (inventoryIds.length > 0) {
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(inventoryIds));
|
||||
|
||||
await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
await refreshUserBalance();
|
||||
await refreshInventoryAjax();
|
||||
topDropItems = [];
|
||||
resetCaseUI();
|
||||
} catch (error) {
|
||||
Notify.error('Ошибка соединения');
|
||||
}
|
||||
|
||||
await refreshUserBalance();
|
||||
await refreshInventoryAjax();
|
||||
topDropItems = [];
|
||||
resetCaseUI();
|
||||
} catch (error) {
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== ЭФФЕКТЫ =====
|
||||
@@ -1726,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>` : ''}
|
||||
@@ -1748,103 +1743,106 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function sellResultItem(itemId) {
|
||||
if (!confirm('Продать этот предмет?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/items');
|
||||
const items = await response.json();
|
||||
const inventoryItem = items.find(i => i.item_id === itemId);
|
||||
|
||||
if (!inventoryItem) {
|
||||
alert('Предмет не найден в инвентаре');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_id', inventoryItem.id);
|
||||
|
||||
const sellResponse = await fetch('/web/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await sellResponse.json();
|
||||
|
||||
if (data.success) {
|
||||
await refreshUserBalance();
|
||||
await refreshInventoryAjax();
|
||||
// Remove the sold item card from display
|
||||
const cards = document.querySelectorAll('.result-card-enhanced');
|
||||
for (const card of cards) {
|
||||
const btn = card.querySelector('.btn-sell-result');
|
||||
if (btn && btn.getAttribute('onclick')?.includes(itemId)) {
|
||||
card.style.transition = 'all 0.3s ease';
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
break;
|
||||
}
|
||||
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 &&
|
||||
Math.abs(i.float - (itemFloat || 0)) < 0.001);
|
||||
|
||||
if (!inventoryItem) {
|
||||
Notify.error('Предмет не найден в инвентаре');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при продаже');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_id', inventoryItem.id);
|
||||
|
||||
const sellResponse = await fetch('/web/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await sellResponse.json();
|
||||
|
||||
if (data.success) {
|
||||
Notify.success('Предмет продан за ' + data.sold_price.toFixed(0) + ' ₽');
|
||||
await refreshUserBalance();
|
||||
await refreshInventoryAjax();
|
||||
const cards = document.querySelectorAll('.result-card-enhanced');
|
||||
for (const card of cards) {
|
||||
const btn = card.querySelector('.btn-sell-result');
|
||||
if (btn && btn.getAttribute('onclick')?.includes(itemId)) {
|
||||
card.style.transition = 'all 0.3s ease';
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Notify.error(data.error || 'Ошибка при продаже');
|
||||
}
|
||||
} catch (error) {
|
||||
Notify.error('Ошибка соединения');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sellAllResults() {
|
||||
if (!window.lastOpenedItems || window.lastOpenedItems.length === 0) {
|
||||
alert('Нет предметов для продажи');
|
||||
Notify.error('Нет предметов для продажи');
|
||||
return;
|
||||
}
|
||||
|
||||
const total = window.lastOpenedItems.reduce((sum, i) => sum + (i.price || 100), 0);
|
||||
if (!confirm(`Продать все выпавшие предметы за ${total.toLocaleString()} ₽?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/items');
|
||||
const items = await response.json();
|
||||
|
||||
const inventoryIds = [];
|
||||
for (const resultItem of window.lastOpenedItems) {
|
||||
const invItem = items.find(i => i.item_id === resultItem.id &&
|
||||
Math.abs(i.float - resultItem.float) < 0.001);
|
||||
if (invItem) {
|
||||
inventoryIds.push(invItem.id);
|
||||
Notify.confirm('Продать все выпавшие предметы за ' + total.toLocaleString() + ' ₽?', 'Продажа', async function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/items');
|
||||
const items = await response.json();
|
||||
|
||||
const inventoryIds = [];
|
||||
for (const resultItem of window.lastOpenedItems) {
|
||||
const invItem = items.find(i => i.item_id === resultItem.id &&
|
||||
Math.abs(i.float - resultItem.float) < 0.001);
|
||||
if (invItem) {
|
||||
inventoryIds.push(invItem.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inventoryIds.length === 0) {
|
||||
alert('Предметы не найдены в инвентаре');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(inventoryIds));
|
||||
|
||||
const sellResponse = await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await sellResponse.json();
|
||||
|
||||
if (inventoryIds.length === 0) {
|
||||
Notify.error('Предметы не найдены в инвентаре');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(inventoryIds));
|
||||
|
||||
const sellResponse = await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await sellResponse.json();
|
||||
|
||||
if (data.success) {
|
||||
await refreshUserBalance();
|
||||
await refreshInventoryAjax();
|
||||
// Clear results and reset UI
|
||||
const resultsDiv = document.getElementById('openingResults');
|
||||
resultsDiv.style.display = 'none';
|
||||
resultsDiv.innerHTML = '';
|
||||
window.lastOpenedItems = [];
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при продаже');
|
||||
if (data.success) {
|
||||
Notify.success('Продано ' + data.sold_count + ' предметов за ' + data.total_received.toFixed(0) + ' ₽');
|
||||
await refreshUserBalance();
|
||||
await refreshInventoryAjax();
|
||||
const resultsDiv = document.getElementById('openingResults');
|
||||
resultsDiv.style.display = 'none';
|
||||
resultsDiv.innerHTML = '';
|
||||
window.lastOpenedItems = [];
|
||||
} else {
|
||||
Notify.error(data.error || 'Ошибка при продаже');
|
||||
}
|
||||
} catch (error) {
|
||||
Notify.error('Ошибка соединения');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function filterItems(rarity) {
|
||||
@@ -1877,16 +1875,17 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script src="/static/js/app.min.js"></script>
|
||||
<script>
|
||||
function handleAchievements(data) {
|
||||
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
||||
data.achievements_unlocked.forEach(ach => {
|
||||
const title = ach.title || ach.name || 'Достижение';
|
||||
const reward = ach.reward || '';
|
||||
showAchievementPopup(title, reward);
|
||||
showAchievementPopup(title);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+154
-34
@@ -4,7 +4,7 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
<style>
|
||||
.cases-sections {
|
||||
display: flex;
|
||||
@@ -152,44 +152,122 @@
|
||||
.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>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.2f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="cases-container">
|
||||
<div class="container">
|
||||
<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">
|
||||
@@ -203,7 +281,7 @@
|
||||
{% else %}
|
||||
<span style="font-size: 3rem;">📦</span>
|
||||
{% endif %}
|
||||
<span class="case-price-badge">{{ "%.0f"|format(case.price) }} ₽</span>
|
||||
<span class="case-price-badge">{{ case.price|money }} ₽</span>
|
||||
</div>
|
||||
<div class="case-info">
|
||||
<div class="case-title">{{ case.display_name }}</div>
|
||||
@@ -222,7 +300,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="case-footer">
|
||||
<span class="case-price">{{ "%.0f"|format(case.price) }} ₽</span>
|
||||
<span class="case-price">{{ case.price|money }} ₽</span>
|
||||
<span class="btn-open">Открыть</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,14 +313,56 @@
|
||||
</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' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
function toggleSound() {
|
||||
|
||||
@@ -4,36 +4,10 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link active">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link active">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<style>
|
||||
.ct-page { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
|
||||
@@ -682,9 +656,9 @@
|
||||
ctClosePaper();
|
||||
setTimeout(() => ctShowResult(data.received_item, data.is_knife_contract), 200);
|
||||
} else {
|
||||
SoundManager.error(); alert(data.error || 'Ошибка'); ctClosePaper();
|
||||
SoundManager.error(); Notify.error(data.error || 'Ошибка'); ctClosePaper();
|
||||
}
|
||||
} catch(_) { SoundManager.error(); alert('Ошибка соединения'); ctClosePaper(); }
|
||||
} catch(_) { SoundManager.error(); Notify.error('Ошибка соединения'); ctClosePaper(); }
|
||||
btn.disabled = false; btn.textContent = '✍ ПОДПИСАТЬ';
|
||||
}
|
||||
|
||||
@@ -711,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();
|
||||
@@ -730,8 +704,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/app.min.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
||||
</body>
|
||||
|
||||
+17
-32
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crash - CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
<style>
|
||||
.crash-layout { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.crash-canvas-container {
|
||||
@@ -65,25 +65,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link active">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="container" style="padding: 2rem 1rem;">
|
||||
<div class="crash-layout">
|
||||
@@ -134,8 +116,9 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
const canvas = document.getElementById('crashCanvas');
|
||||
@@ -462,17 +445,18 @@
|
||||
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();
|
||||
alert(data.error);
|
||||
Notify.error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
SoundManager.error();
|
||||
alert('Ошибка соединения');
|
||||
Notify.error('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,17 +469,18 @@
|
||||
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();
|
||||
alert(data.error);
|
||||
Notify.error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
SoundManager.error();
|
||||
alert('Ошибка соединения');
|
||||
Notify.error('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-16
@@ -4,23 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CS2 Trade-Up Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="hero">
|
||||
<div class="container">
|
||||
@@ -52,7 +39,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/app.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,802 +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>
|
||||
.inventory-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.select-all-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-all-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sell-selected-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sell-selected-btn:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sell-selected-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sell-all-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: transparent;
|
||||
color: var(--danger-color);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sell-all-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.inventory-skin-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-select-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.inventory-skin-card.selected-for-sale {
|
||||
border-color: var(--danger-color);
|
||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.selected-count-badge {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.total-price-badge {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-sell-single {
|
||||
flex: 1;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-sell-single:hover {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bulk-sell-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.bulk-sell-content {
|
||||
background: var(--bg-card);
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.bulk-sell-content h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bulk-sell-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.bulk-sell-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bulk-sell-option:hover {
|
||||
background: var(--bg-light);
|
||||
}
|
||||
|
||||
.bulk-sell-option input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.bulk-sell-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.2f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="inventory-container">
|
||||
<div class="container">
|
||||
<div class="inventory-header">
|
||||
<div>
|
||||
<h1>🎒 Инвентарь</h1>
|
||||
<p class="page-subtitle">Всего предметов: <span id="totalItemsCount">{{ inventory_items|length }}</span></p>
|
||||
</div>
|
||||
|
||||
<div class="inventory-actions">
|
||||
<div class="selection-controls">
|
||||
<label class="select-all-checkbox">
|
||||
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()">
|
||||
<span>Выбрать все</span>
|
||||
</label>
|
||||
<span class="selected-count-badge" id="selectedCountBadge" style="display: none;">0</span>
|
||||
<span class="total-price-badge" id="totalPriceBadge" style="display: none;">0 ₽</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button class="sell-selected-btn" id="sellSelectedBtn" onclick="sellSelected()" disabled>
|
||||
💰 Продать выбранные
|
||||
</button>
|
||||
<button class="sell-all-btn" onclick="openBulkSellModal()">
|
||||
📦 Массовая продажа
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inventory-filters">
|
||||
<div class="filter-bar">
|
||||
<select id="rarityFilter" class="filter-select" onchange="filterItems()">
|
||||
<option value="">Все редкости</option>
|
||||
{% for rarity in rarities %}
|
||||
<option value="{{ rarity }}">{{ rarity }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<select id="typeFilter" class="filter-select" onchange="filterItems()">
|
||||
<option value="">Все типы</option>
|
||||
{% for type in types %}
|
||||
<option value="{{ type }}">{{ type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<input type="text" id="searchInput" class="filter-select"
|
||||
placeholder="Поиск по названию..." oninput="filterItems()">
|
||||
|
||||
<button onclick="resetFilters()" class="btn btn-outline">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if inventory_items %}
|
||||
<div class="inventory-grid-detailed" id="inventoryGrid">
|
||||
{% for item in inventory_items %}
|
||||
<div class="inventory-skin-card rarity-{{ item.rarity|lower|replace(' ', '-') }}"
|
||||
data-inventory-id="{{ item.id }}"
|
||||
data-rarity="{{ item.rarity }}"
|
||||
data-type="{{ item.type }}"
|
||||
data-name="{{ item.name|lower }}"
|
||||
data-price="{{ item.price_rub or 100 }}">
|
||||
<input type="checkbox" class="item-select-checkbox"
|
||||
onchange="onItemSelect(this)"
|
||||
data-id="{{ item.id }}"
|
||||
data-price="{{ item.price_rub or 100 }}">
|
||||
|
||||
<div class="skin-image-container">
|
||||
<img src="{{ item.image_url or '/static/placeholder.png' }}"
|
||||
alt="{{ item.name }}"
|
||||
class="skin-image"
|
||||
onerror="this.src='/static/placeholder.png'">
|
||||
</div>
|
||||
<div class="skin-info">
|
||||
<div class="skin-name" title="{{ item.name }}">{{ item.name }}</div>
|
||||
<div class="skin-details">
|
||||
<span class="rarity-{{ item.rarity|lower|replace(' ', '-') }}">{{ item.rarity }}</span>
|
||||
<span>Float: {{ "%.6f"|format(item.float) }}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top: 5px;">
|
||||
<span>{{ item.type }}</span>
|
||||
<span>{{ item.wear }}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top: 5px; color: var(--success-color);">
|
||||
<span>💰 {{ "%.0f"|format(item.price_rub or 100) }} ₽</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button onclick="sellSingleItem({{ item.id }})" class="btn-sell-single">
|
||||
Продать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state-large">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h3>Ваш инвентарь пуст</h3>
|
||||
<p>Откройте кейсы, чтобы получить первые предметы!</p>
|
||||
<a href="/cases" class="btn btn-primary">Открыть кейсы</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Модальное окно массовой продажи -->
|
||||
<div id="bulkSellModal" class="bulk-sell-modal" style="display: none;">
|
||||
<div class="bulk-sell-content">
|
||||
<h3>📦 Массовая продажа</h3>
|
||||
<p>Выберите редкости предметов для продажи:</p>
|
||||
|
||||
<div class="bulk-sell-options" id="bulkSellOptions">
|
||||
<!-- Опции будут добавлены через JS -->
|
||||
</div>
|
||||
|
||||
<div style="margin: 1rem 0;">
|
||||
<p>Будет продано: <strong id="bulkSellCount">0</strong> предметов</p>
|
||||
<p>Сумма: <strong id="bulkSellTotal" style="color: var(--success-color);">0 ₽</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="bulk-sell-actions">
|
||||
<button class="btn btn-outline" onclick="closeBulkSellModal()">Отмена</button>
|
||||
<button class="btn btn-primary" onclick="executeBulkSell()" style="background: var(--danger-color);">
|
||||
Продать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedItems = new Set();
|
||||
let itemPrices = new Map();
|
||||
|
||||
// Инициализация цен
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
const id = parseInt(card.dataset.inventoryId);
|
||||
const price = parseFloat(card.dataset.price);
|
||||
itemPrices.set(id, price);
|
||||
});
|
||||
|
||||
function onItemSelect(checkbox) {
|
||||
const card = checkbox.closest('.inventory-skin-card');
|
||||
const itemId = parseInt(checkbox.dataset.id);
|
||||
|
||||
if (checkbox.checked) {
|
||||
selectedItems.add(itemId);
|
||||
card.classList.add('selected-for-sale');
|
||||
} else {
|
||||
selectedItems.delete(itemId);
|
||||
card.classList.remove('selected-for-sale');
|
||||
document.getElementById('selectAllCheckbox').checked = false;
|
||||
}
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const selectAll = document.getElementById('selectAllCheckbox');
|
||||
const checkboxes = document.querySelectorAll('.item-select-checkbox');
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
if (cb.closest('.inventory-skin-card').style.display !== 'none') {
|
||||
cb.checked = selectAll.checked;
|
||||
const card = cb.closest('.inventory-skin-card');
|
||||
const itemId = parseInt(cb.dataset.id);
|
||||
|
||||
if (selectAll.checked) {
|
||||
selectedItems.add(itemId);
|
||||
card.classList.add('selected-for-sale');
|
||||
} else {
|
||||
selectedItems.delete(itemId);
|
||||
card.classList.remove('selected-for-sale');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function updateSelectionUI() {
|
||||
const count = selectedItems.size;
|
||||
const countBadge = document.getElementById('selectedCountBadge');
|
||||
const priceBadge = document.getElementById('totalPriceBadge');
|
||||
const sellBtn = document.getElementById('sellSelectedBtn');
|
||||
|
||||
if (count > 0) {
|
||||
countBadge.style.display = 'inline-block';
|
||||
countBadge.textContent = `${count} выбрано`;
|
||||
|
||||
let total = 0;
|
||||
selectedItems.forEach(id => {
|
||||
total += itemPrices.get(id) || 100;
|
||||
});
|
||||
|
||||
priceBadge.style.display = 'inline-block';
|
||||
priceBadge.textContent = `${total.toFixed(0)} ₽`;
|
||||
|
||||
sellBtn.disabled = false;
|
||||
} else {
|
||||
countBadge.style.display = 'none';
|
||||
priceBadge.style.display = 'none';
|
||||
sellBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBalance() {
|
||||
try {
|
||||
const response = await fetch('/web/api/user/balance');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
document.querySelectorAll('.user-balance').forEach(el => {
|
||||
el.textContent = `${Math.floor(data.balance)} ₽`;
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function sellSingleItem(inventoryId) {
|
||||
if (!confirm('Продать этот предмет?')) return;
|
||||
SoundManager.click();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_id', inventoryId);
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
SoundManager.balanceUpdate();
|
||||
const card = document.querySelector(`.inventory-skin-card[data-id="${inventoryId}"]`);
|
||||
if (card) {
|
||||
card.style.transition = 'all 0.3s';
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
}
|
||||
await refreshBalance();
|
||||
} else {
|
||||
SoundManager.error();
|
||||
alert(data.error || 'Ошибка при продаже');
|
||||
}
|
||||
} catch (error) {
|
||||
SoundManager.error();
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
async function sellSelected() {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
let total = 0;
|
||||
selectedItems.forEach(id => {
|
||||
total += itemPrices.get(id) || 100;
|
||||
});
|
||||
|
||||
if (!confirm(`Продать ${selectedItems.size} предметов за ${total.toFixed(0)} ₽?`)) return;
|
||||
SoundManager.click();
|
||||
|
||||
const ids = Array.from(selectedItems);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(ids));
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
selectedItems.forEach(id => {
|
||||
const card = document.querySelector(`.inventory-skin-card[data-id="${id}"]`);
|
||||
if (card) {
|
||||
card.style.transition = 'all 0.3s';
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
}
|
||||
});
|
||||
selectedItems.clear();
|
||||
await refreshBalance();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при продаже');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
function openBulkSellModal() {
|
||||
const modal = document.getElementById('bulkSellModal');
|
||||
const optionsDiv = document.getElementById('bulkSellOptions');
|
||||
|
||||
// Собираем уникальные редкости из видимых предметов
|
||||
const rarities = new Set();
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
rarities.add(card.dataset.rarity);
|
||||
}
|
||||
});
|
||||
|
||||
// Создаем опции
|
||||
optionsDiv.innerHTML = '';
|
||||
Array.from(rarities).sort().forEach(rarity => {
|
||||
const count = document.querySelectorAll(`.inventory-skin-card[data-rarity="${rarity}"]`).length;
|
||||
|
||||
const option = document.createElement('label');
|
||||
option.className = 'bulk-sell-option';
|
||||
option.innerHTML = `
|
||||
<input type="checkbox" value="${rarity}" onchange="updateBulkSellCount()">
|
||||
<span style="flex: 1;">
|
||||
<span class="rarity-${rarity.toLowerCase().replace(/ /g, '-')}">${rarity}</span>
|
||||
</span>
|
||||
<span style="color: var(--text-secondary);">${count} шт.</span>
|
||||
`;
|
||||
optionsDiv.appendChild(option);
|
||||
});
|
||||
|
||||
// Добавляем опцию "Все"
|
||||
const allOption = document.createElement('label');
|
||||
allOption.className = 'bulk-sell-option';
|
||||
allOption.innerHTML = `
|
||||
<input type="checkbox" value="all" onchange="toggleAllRarities(this)">
|
||||
<span style="flex: 1; font-weight: 500;">Все предметы</span>
|
||||
<span style="color: var(--text-secondary);" id="totalVisibleCount">0</span>
|
||||
`;
|
||||
optionsDiv.insertBefore(allOption, optionsDiv.firstChild);
|
||||
|
||||
updateVisibleCount();
|
||||
updateBulkSellCount();
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeBulkSellModal() {
|
||||
document.getElementById('bulkSellModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function toggleAllRarities(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('#bulkSellOptions input[type="checkbox"]');
|
||||
checkboxes.forEach(cb => {
|
||||
if (cb !== checkbox) {
|
||||
cb.checked = checkbox.checked;
|
||||
}
|
||||
});
|
||||
updateBulkSellCount();
|
||||
}
|
||||
|
||||
function updateVisibleCount() {
|
||||
const visibleCards = document.querySelectorAll('.inventory-skin-card:not([style*="display: none"])');
|
||||
document.getElementById('totalVisibleCount').textContent = visibleCards.length;
|
||||
}
|
||||
|
||||
function updateBulkSellCount() {
|
||||
const selectedRarities = [];
|
||||
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => {
|
||||
if (cb.value && cb.value !== 'all') {
|
||||
selectedRarities.push(cb.value);
|
||||
}
|
||||
});
|
||||
|
||||
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
|
||||
|
||||
let count = 0;
|
||||
let total = 0;
|
||||
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
const rarity = card.dataset.rarity;
|
||||
if (allChecked || selectedRarities.includes(rarity)) {
|
||||
count++;
|
||||
total += parseFloat(card.dataset.price) || 100;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('bulkSellCount').textContent = count;
|
||||
document.getElementById('bulkSellTotal').textContent = `${total.toFixed(0)} ₽`;
|
||||
}
|
||||
|
||||
async function executeBulkSell() {
|
||||
const selectedRarities = [];
|
||||
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => {
|
||||
if (cb.value && cb.value !== 'all') {
|
||||
selectedRarities.push(cb.value);
|
||||
}
|
||||
});
|
||||
|
||||
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
|
||||
|
||||
const idsToSell = [];
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
const rarity = card.dataset.rarity;
|
||||
if (allChecked || selectedRarities.includes(rarity)) {
|
||||
idsToSell.push(parseInt(card.dataset.inventoryId));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (idsToSell.length === 0) {
|
||||
alert('Выберите хотя бы одну редкость');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Продать ${idsToSell.length} предметов?`)) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inventory_ids', JSON.stringify(idsToSell));
|
||||
|
||||
try {
|
||||
const response = await fetch('/web/api/inventory/sell/bulk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
await refreshBalance();
|
||||
// Remove all cards with animation
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||
card.style.transition = 'all 0.3s';
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
});
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при продаже');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems() {
|
||||
const rarityFilter = document.getElementById('rarityFilter').value.toLowerCase();
|
||||
const typeFilter = document.getElementById('typeFilter').value.toLowerCase();
|
||||
const searchQuery = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
const items = document.querySelectorAll('.inventory-skin-card');
|
||||
let visibleCount = 0;
|
||||
|
||||
items.forEach(item => {
|
||||
const rarity = item.dataset.rarity.toLowerCase();
|
||||
const type = item.dataset.type.toLowerCase();
|
||||
const name = item.dataset.name;
|
||||
|
||||
let visible = true;
|
||||
|
||||
if (rarityFilter && rarity !== rarityFilter) visible = false;
|
||||
if (typeFilter && type !== typeFilter) visible = false;
|
||||
if (searchQuery && !name.includes(searchQuery)) visible = false;
|
||||
|
||||
item.style.display = visible ? 'block' : 'none';
|
||||
if (visible) visibleCount++;
|
||||
});
|
||||
|
||||
document.getElementById('totalItemsCount').textContent = visibleCount;
|
||||
|
||||
// Сбрасываем выбор при фильтрации
|
||||
selectedItems.clear();
|
||||
document.querySelectorAll('.item-select-checkbox').forEach(cb => cb.checked = false);
|
||||
document.querySelectorAll('.inventory-skin-card').forEach(c => c.classList.remove('selected-for-sale'));
|
||||
document.getElementById('selectAllCheckbox').checked = false;
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('rarityFilter').value = '';
|
||||
document.getElementById('typeFilter').value = '';
|
||||
document.getElementById('searchInput').value = '';
|
||||
filterItems();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// Закрытие модального окна по клику вне
|
||||
document.getElementById('bulkSellModal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('bulk-sell-modal')) {
|
||||
closeBulkSellModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script>
|
||||
function toggleSound() {
|
||||
const enabled = SoundManager.toggle();
|
||||
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('soundToggle');
|
||||
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Живое обновление через WebSocket
|
||||
WS.on('inventory_update', () => refreshInventory());
|
||||
WS.on('item_sold', () => refreshInventory());
|
||||
WS.on('balance_update', () => { if (typeof refreshBalance === 'function') refreshBalance(); });
|
||||
|
||||
let refreshTimeout;
|
||||
function refreshInventory() {
|
||||
clearTimeout(refreshTimeout);
|
||||
refreshTimeout = setTimeout(() => {
|
||||
fetch('/web/api/inventory/items')
|
||||
.then(r => r.json())
|
||||
.then(items => rebuildInventoryGrid(items))
|
||||
.catch(() => {});
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function rebuildInventoryGrid(items) {
|
||||
const grid = document.getElementById('inventoryGrid');
|
||||
if (!grid) return;
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<div class="empty-state-large"><div class="empty-icon">📭</div><h3>Ваш инвентарь пуст</h3><p>Откройте кейсы, чтобы получить первые предметы!</p><a href="/cases" class="btn btn-primary">Открыть кейсы</a></div>';
|
||||
document.getElementById('selectAllCheckbox') && (document.getElementById('selectAllCheckbox').checked = false);
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
items.forEach(item => {
|
||||
const rarityClass = (item.rarity || 'unknown').toLowerCase().replace(/\s+/g, '-');
|
||||
const image = item.image_url || '/static/placeholder.png';
|
||||
const floatStr = item.float !== null && item.float !== undefined ? 'Float: ' + item.float.toFixed(6) : '';
|
||||
const wearStr = item.wear || '';
|
||||
const typeStr = item.type || '';
|
||||
html += `<div class="inventory-skin-card rarity-${rarityClass}"
|
||||
data-inventory-id="${item.id}"
|
||||
data-rarity="${item.rarity || ''}"
|
||||
data-type="${typeStr}"
|
||||
data-name="${(item.name || '').toLowerCase()}"
|
||||
data-price="${item.price_rub || 100}">
|
||||
<input type="checkbox" class="item-select-checkbox"
|
||||
onchange="onItemSelect(this)"
|
||||
data-id="${item.id}"
|
||||
data-price="${item.price_rub || 100}">
|
||||
<div class="skin-image-container">
|
||||
<img src="${image}" alt="${item.name}" class="skin-image" onerror="this.src='/static/placeholder.png'">
|
||||
</div>
|
||||
<div class="skin-info">
|
||||
<div class="skin-name" title="${item.name}">${item.name}</div>
|
||||
<div class="skin-details">
|
||||
<span class="rarity-${rarityClass}">${item.rarity || ''}</span>
|
||||
<span>${floatStr}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top:5px">
|
||||
<span>${typeStr}</span>
|
||||
<span>${wearStr}</span>
|
||||
</div>
|
||||
<div class="skin-details" style="margin-top:5px;color:var(--success-color)">
|
||||
<span>💰 ${(item.price_rub || 100).toLocaleString()} ₽</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button onclick="sellSingleItem(${item.id})" class="btn-sell-single">Продать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
document.getElementById('selectAllCheckbox') && (document.getElementById('selectAllCheckbox').checked = false);
|
||||
if (typeof filterItems === 'function') filterItems();
|
||||
}
|
||||
</script>
|
||||
{% include '_activity_sidebar.html' %}
|
||||
</body>
|
||||
</html>
|
||||
+2
-10
@@ -4,18 +4,10 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/register" class="btn btn-outline">Регистрация</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="auth-container">
|
||||
<div class="auth-card">
|
||||
|
||||
+1262
-78
File diff suppressed because it is too large
Load Diff
+2
-10
@@ -4,18 +4,10 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="auth-container">
|
||||
<div class="auth-card">
|
||||
|
||||
+25
-59
@@ -4,38 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover">
|
||||
<title>Апгрейд — CS2 Simulator</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||
</head>
|
||||
<body class="upgrade-page">
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||||
<div class="nav-links">
|
||||
{% if user %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link active">🆙 Апгрейд</a>
|
||||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||||
<a href="/inventory" class="nav-link">Инвентарь</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||||
<a href="/profile" class="nav-link">Профиль</a>
|
||||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||||
{% else %}
|
||||
<a href="/cases" class="nav-link">Кейсы</a>
|
||||
<a href="/contracts" class="nav-link">Контракты</a>
|
||||
<a href="/upgrade" class="nav-link active">🆙 Апгрейд</a>
|
||||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||||
<a href="/login" class="btn btn-outline">Войти</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% include "_nav.html" %}
|
||||
|
||||
<main class="upgrade-container">
|
||||
<div class="container">
|
||||
@@ -109,7 +81,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Target -->
|
||||
@@ -475,7 +446,7 @@
|
||||
if (isSpinning) return;
|
||||
const card = event.target.closest('.target-item-card');
|
||||
if (card.classList.contains('disabled')) {
|
||||
alert('Этот предмет не подходит!');
|
||||
Notify.error('Этот предмет не подходит!');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -546,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) {}
|
||||
}
|
||||
@@ -596,19 +568,6 @@
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function executeUpgrade() {
|
||||
if (!selectedInput || !selectedTarget || isSpinning) return;
|
||||
|
||||
@@ -651,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;
|
||||
@@ -676,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);
|
||||
@@ -703,18 +666,19 @@
|
||||
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);
|
||||
document.getElementById('displayTargetPrice').textContent = `${Math.floor(data.received_item.price).toLocaleString()} ₽`;
|
||||
document.getElementById('selectedTargetDisplay').style.borderColor = '#10b981';
|
||||
document.getElementById('selectedTargetDisplay').style.boxShadow = '0 0 20px rgba(16,185,129,0.3)';
|
||||
showToast(`${getRandomPhrase('win')} ${data.received_item.name}`, 'success');
|
||||
Notify.success(`${getRandomPhrase('win')} ${data.received_item.name}`);
|
||||
SoundManager.wheelWin();
|
||||
} else {
|
||||
document.getElementById('selectedInputDisplay').style.borderColor = '#ef4444';
|
||||
document.getElementById('selectedInputDisplay').style.boxShadow = '0 0 20px rgba(239,68,68,0.3)';
|
||||
showToast(getRandomPhrase('lose'), 'fail');
|
||||
Notify.error(getRandomPhrase('lose'));
|
||||
SoundManager.wheelLose();
|
||||
}
|
||||
|
||||
@@ -752,13 +716,13 @@
|
||||
// Запасной таймер на случай если transitionend не сработает
|
||||
setTimeout(showResult, spinDuration * 1000 + 300);
|
||||
} else {
|
||||
alert(data.error || 'Ошибка');
|
||||
Notify.error(data.error || 'Ошибка');
|
||||
isSpinning = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🎲 Апгрейд';
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка соединения');
|
||||
Notify.error('Ошибка соединения');
|
||||
isSpinning = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🎲 Апгрейд';
|
||||
@@ -777,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
|
||||
@@ -795,16 +759,18 @@
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/sounds.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/sounds.min.js"></script>
|
||||
<script src="/static/js/websocket.min.js"></script>
|
||||
<script src="/static/js/notifications.min.js"></script>
|
||||
<script src="/static/js/safemode.js"></script>
|
||||
<script src="/static/js/app.min.js"></script>
|
||||
<script>
|
||||
function handleAchievements(data) {
|
||||
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
||||
data.achievements_unlocked.forEach(ach => {
|
||||
const title = ach.title || ach.name || 'Достижение';
|
||||
const reward = ach.reward || '';
|
||||
showAchievementPopup(title, reward);
|
||||
showAchievementPopup(title);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+92
-33
@@ -1,91 +1,150 @@
|
||||
import random
|
||||
import math
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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
|
||||
|
||||
|
||||
async def get_upgrade_rpu_async(db: AsyncSession, user_id: int) -> UpgradeRPU:
|
||||
result = await db.execute(select(UpgradeRPU).where(UpgradeRPU.user_id == user_id))
|
||||
rpu = result.scalar_one_or_none()
|
||||
if not rpu:
|
||||
rpu = UpgradeRPU(user_id=user_id)
|
||||
db.add(rpu)
|
||||
from rpu import _rpu_commit_or_flush_async
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
await db.refresh(rpu)
|
||||
return rpu
|
||||
|
||||
|
||||
def calculate_upgrade_probability(input_price: float, target_price: float) -> float:
|
||||
if input_price <= 0 or target_price <= 0:
|
||||
return 50.0
|
||||
|
||||
if target_price <= input_price:
|
||||
return -1.0
|
||||
|
||||
probability = (input_price / target_price) * 100
|
||||
|
||||
return max(1.0, min(75.0, probability))
|
||||
|
||||
|
||||
def get_adjusted_upgrade_probability(db: Session, user_id: int, base_probability: float) -> float:
|
||||
if base_probability < 0:
|
||||
return -1.0
|
||||
|
||||
rpu = get_upgrade_rpu(db, user_id)
|
||||
adjusted = base_probability * rpu.upgrade_multiplier
|
||||
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))
|
||||
|
||||
|
||||
async def get_adjusted_upgrade_probability_async(db: AsyncSession, user_id: int, base_probability: float) -> float:
|
||||
if base_probability < 0:
|
||||
return -1.0
|
||||
rpu = await get_upgrade_rpu_async(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)
|
||||
|
||||
if success:
|
||||
rotations = random.randint(7, 9)
|
||||
lo = max(0, 180 - half_green + margin)
|
||||
hi = min(360, 180 + half_green - margin)
|
||||
if lo < hi:
|
||||
target_angle = random.uniform(lo, hi)
|
||||
else:
|
||||
target_angle = random.uniform(max(0, 180 - half_green + 1), min(360, 180 + half_green - 1))
|
||||
target_angle = random.uniform(lo, hi) if lo < hi else random.uniform(
|
||||
max(0, 180 - half_green + 1), min(360, 180 + half_green - 1))
|
||||
else:
|
||||
rotations = random.randint(6, 8)
|
||||
left_size = max(0, 180 - half_green - 3)
|
||||
right_start = min(360, 180 + half_green + 3)
|
||||
right_size = max(0, 360 - right_start)
|
||||
if left_size >= right_size:
|
||||
lo = 0
|
||||
hi = max(0, 180 - half_green - 3)
|
||||
else:
|
||||
lo = min(360, 180 + half_green + 3)
|
||||
hi = 360
|
||||
if lo < hi:
|
||||
target_angle = random.uniform(lo, hi)
|
||||
else:
|
||||
target_angle = random.uniform(0, 360)
|
||||
|
||||
lo, hi = (0, max(0, 180 - half_green - 3)) if left_size >= right_size else (min(360, 180 + half_green + 3), 360)
|
||||
target_angle = random.uniform(lo, hi) if lo < hi else random.uniform(min(360, 180 + half_green + 3), 360)
|
||||
final_angle = target_angle + rotations * 360
|
||||
|
||||
return success, final_angle, rotations, raw_angle
|
||||
|
||||
|
||||
def update_upgrade_stats(db: Session, user_id: int, success: bool, input_price: float):
|
||||
rpu = get_upgrade_rpu(db, user_id)
|
||||
rpu.total_attempts += 1
|
||||
if success:
|
||||
rpu.total_success += 1
|
||||
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
|
||||
|
||||
if rpu.auto_adjust and rpu.total_attempts >= 15:
|
||||
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 >= 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()
|
||||
|
||||
async def update_upgrade_stats_async(db: AsyncSession, user_id: int, success: bool, input_price: float):
|
||||
rpu = await get_upgrade_rpu_async(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 >= 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.5, rpu.upgrade_multiplier + 0.1)
|
||||
elif rate > 0.6:
|
||||
rpu.upgrade_multiplier = max(0.3, rpu.upgrade_multiplier - 0.1)
|
||||
from rpu import _rpu_commit_or_flush_async
|
||||
await _rpu_commit_or_flush_async(db)
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import random
|
||||
import math
|
||||
import time
|
||||
from typing import Set, Dict, Optional
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from contextlib import asynccontextmanager
|
||||
import aioredis
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
CRASH_SPEED = 0.08
|
||||
CRASH_MIN_WAIT = 5.0
|
||||
CRASH_MAX_WAIT = 10.0
|
||||
CRASH_RUNNING_TIME = 60.0
|
||||
CRASH_COOLDOWN = 3.0
|
||||
|
||||
app = FastAPI(title="CS2 Simulator WebSocket Server")
|
||||
|
||||
|
||||
class RedisPubSub:
|
||||
def __init__(self):
|
||||
self.pub: Optional[aioredis.Redis] = None
|
||||
self.sub: Optional[aioredis.Redis] = None
|
||||
|
||||
async def connect(self):
|
||||
self.pub = await aioredis.from_url(REDIS_URL, decode_responses=True)
|
||||
self.sub = await aioredis.from_url(REDIS_URL, decode_responses=True)
|
||||
|
||||
async def publish(self, channel: str, message: dict):
|
||||
if self.pub:
|
||||
await self.pub.publish(channel, json.dumps(message, default=str))
|
||||
|
||||
async def subscribe(self, channel: str):
|
||||
if self.sub:
|
||||
psub = self.sub.pubsub()
|
||||
await psub.subscribe(channel)
|
||||
return psub
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
if self.pub:
|
||||
await self.pub.close()
|
||||
if self.sub:
|
||||
await self.sub.close()
|
||||
|
||||
|
||||
redis_pubsub = RedisPubSub()
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[int, Set[WebSocket]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: int):
|
||||
await websocket.accept()
|
||||
async with self._lock:
|
||||
if user_id not in self.active_connections:
|
||||
self.active_connections[user_id] = set()
|
||||
self.active_connections[user_id].add(websocket)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, user_id: int):
|
||||
async with self._lock:
|
||||
if user_id in self.active_connections:
|
||||
self.active_connections[user_id].discard(websocket)
|
||||
if not self.active_connections[user_id]:
|
||||
del self.active_connections[user_id]
|
||||
|
||||
async def send_personal(self, user_id: int, message: dict):
|
||||
async with self._lock:
|
||||
if user_id in self.active_connections:
|
||||
dead = set()
|
||||
for ws in self.active_connections[user_id]:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
self.active_connections[user_id] -= dead
|
||||
if not self.active_connections[user_id]:
|
||||
del self.active_connections[user_id]
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
async with self._lock:
|
||||
dead: Dict[int, Set[WebSocket]] = {}
|
||||
for uid, sockets in self.active_connections.items():
|
||||
for ws in sockets:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
dead.setdefault(uid, set()).add(ws)
|
||||
for uid, dset in dead.items():
|
||||
self.active_connections[uid] -= dset
|
||||
if not self.active_connections[uid]:
|
||||
del self.active_connections[uid]
|
||||
|
||||
@property
|
||||
def online_count(self) -> int:
|
||||
return len(self.active_connections)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
class CrashGame:
|
||||
def __init__(self):
|
||||
self.running = False
|
||||
self.current_multiplier = 1.0
|
||||
self.crash_point = 1.0
|
||||
self.bets: Dict[int, float] = {}
|
||||
self.cashed_out: Dict[int, float] = {}
|
||||
self.phase = "waiting"
|
||||
self.timer = 0.0
|
||||
self.round = 0
|
||||
self.history: list = []
|
||||
self._start_time = 0.0
|
||||
|
||||
def generate_crash_point(self) -> float:
|
||||
r = random.random()
|
||||
cp = 1.0 / max(0.01, 1.0 - r * 0.99)
|
||||
return round(min(cp, 1000.0), 2)
|
||||
|
||||
def get_multiplier(self, elapsed: float) -> float:
|
||||
return round(math.exp(elapsed * CRASH_SPEED), 2)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"round": self.round,
|
||||
"current_multiplier": self.current_multiplier,
|
||||
"crash_point": self.crash_point,
|
||||
"timer": int(self.timer),
|
||||
"bet_count": len(self.bets),
|
||||
"total_bets": round(sum(self.bets.values()), 2),
|
||||
"cashed_out": {str(k): v for k, v in self.cashed_out.items()},
|
||||
"history": self.history[-20:],
|
||||
}
|
||||
|
||||
|
||||
crash = CrashGame()
|
||||
|
||||
|
||||
async def crash_game_loop():
|
||||
while True:
|
||||
if crash.phase == "waiting" or crash.phase == "cooldown":
|
||||
wait_time = random.uniform(CRASH_MIN_WAIT, CRASH_MAX_WAIT)
|
||||
for i in range(int(wait_time)):
|
||||
crash.timer = wait_time - i
|
||||
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
|
||||
await asyncio.sleep(1)
|
||||
crash.timer = 0
|
||||
crash.phase = "betting"
|
||||
crash.cashed_out.clear()
|
||||
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
|
||||
await asyncio.sleep(5)
|
||||
crash.phase = "running"
|
||||
crash.round += 1
|
||||
crash.crash_point = crash.generate_crash_point()
|
||||
crash.current_multiplier = 1.0
|
||||
crash._start_time = time.time()
|
||||
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
|
||||
|
||||
if crash.phase == "running":
|
||||
while True:
|
||||
elapsed = time.time() - crash._start_time
|
||||
mult = crash.get_multiplier(elapsed)
|
||||
crash.current_multiplier = mult
|
||||
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
|
||||
if mult >= crash.crash_point:
|
||||
crash.phase = "crashed"
|
||||
crash.history.append({
|
||||
"round": crash.round,
|
||||
"crash_point": crash.crash_point,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
if len(crash.history) > 100:
|
||||
crash.history = crash.history[-100:]
|
||||
await manager.broadcast({
|
||||
"type": "crash_crashed",
|
||||
"crash_point": crash.crash_point,
|
||||
"round": crash.round,
|
||||
})
|
||||
crash.bets.clear()
|
||||
crash.phase = "cooldown"
|
||||
break
|
||||
if mult > 2.0:
|
||||
await asyncio.sleep(0.15)
|
||||
elif mult > 5.0:
|
||||
await asyncio.sleep(0.08)
|
||||
else:
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
await asyncio.sleep(CRASH_COOLDOWN)
|
||||
|
||||
|
||||
async def listen_activity():
|
||||
psub = await redis_pubsub.subscribe("activity")
|
||||
if psub:
|
||||
async for msg in psub.listen():
|
||||
if msg["type"] == "message":
|
||||
data = json.loads(msg["data"])
|
||||
await manager.broadcast(data)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app_instance):
|
||||
await redis_pubsub.connect()
|
||||
asyncio.create_task(crash_game_loop())
|
||||
asyncio.create_task(listen_activity())
|
||||
yield
|
||||
await redis_pubsub.close()
|
||||
|
||||
|
||||
app.router.lifespan_context = lifespan
|
||||
|
||||
|
||||
@app.websocket("/ws/{user_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, user_id: int):
|
||||
await manager.connect(websocket, user_id)
|
||||
await websocket.send_json({"type": "connected", "user_id": user_id})
|
||||
await websocket.send_json({"type": "crash_state", **crash.to_dict()})
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
msg_type = data.get("type")
|
||||
if msg_type == "crash_bet":
|
||||
amount = float(data.get("amount", 0))
|
||||
if amount > 0 and crash.phase == "betting":
|
||||
crash.bets[user_id] = crash.bets.get(user_id, 0) + amount
|
||||
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
|
||||
elif msg_type == "crash_cashout":
|
||||
if crash.phase == "running" and user_id in crash.bets:
|
||||
bet = crash.bets.pop(user_id)
|
||||
crash.cashed_out[user_id] = crash.current_multiplier
|
||||
payout = round(bet * crash.current_multiplier, 2)
|
||||
await redis_pubsub.publish("crash_payout", {
|
||||
"user_id": user_id,
|
||||
"bet": bet,
|
||||
"multiplier": crash.current_multiplier,
|
||||
"payout": payout,
|
||||
})
|
||||
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
|
||||
elif msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
except WebSocketDisconnect:
|
||||
await manager.disconnect(websocket, user_id)
|
||||
crash.bets.pop(user_id, None)
|
||||
await manager.broadcast({"type": "crash_state", **crash.to_dict()})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=13670)
|
||||
Reference in New Issue
Block a user