Compare commits
3 Commits
6638ab99f3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fed81082f | |||
| 17da0b6d79 | |||
| 47c7f17c90 |
+5
-3
@@ -1,6 +1,8 @@
|
|||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.bak
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
cs2_simulator.db
|
venv/
|
||||||
*.log
|
|
||||||
|
|||||||
+737
-2518
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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 auth import get_current_admin
|
||||||
from rpu import get_user_rpu, calculate_rpu_level, get_rpu_description
|
from rpu import get_user_rpu, calculate_rpu_level, get_rpu_description
|
||||||
|
|
||||||
@@ -184,6 +184,48 @@ async def admin_user_detail(
|
|||||||
"inventory": inventory
|
"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)
|
@admin_router.get("/cases", response_class=HTMLResponse)
|
||||||
async def admin_cases(
|
async def admin_cases(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -197,6 +239,7 @@ async def admin_cases(
|
|||||||
for case in cases:
|
for case in cases:
|
||||||
if not isinstance(case.get('items'), list):
|
if not isinstance(case.get('items'), list):
|
||||||
case['items'] = []
|
case['items'] = []
|
||||||
|
case['items_count'] = len(case['items'])
|
||||||
|
|
||||||
all_items = []
|
all_items = []
|
||||||
for i, item in enumerate(ALL_ITEMS[:500]):
|
for i, item in enumerate(ALL_ITEMS[:500]):
|
||||||
@@ -272,7 +315,7 @@ async def admin_give_item(
|
|||||||
if not item_data:
|
if not item_data:
|
||||||
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
|
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(
|
inventory_item = InventoryItem(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -282,6 +325,8 @@ async def admin_give_item(
|
|||||||
wear=item_data.get("wear", "Unknown"),
|
wear=item_data.get("wear", "Unknown"),
|
||||||
float_value=item_float,
|
float_value=item_float,
|
||||||
type=item_data.get("type", "Normal"),
|
type=item_data.get("type", "Normal"),
|
||||||
|
image_url=item_data.get("image_url", ""),
|
||||||
|
price_rub=item_data.get("price_rub", 0),
|
||||||
obtained_from="admin"
|
obtained_from="admin"
|
||||||
)
|
)
|
||||||
db.add(inventory_item)
|
db.add(inventory_item)
|
||||||
@@ -370,6 +415,13 @@ async def admin_get_user_rpu(
|
|||||||
|
|
||||||
rpu = get_user_rpu(db, user_id)
|
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 {
|
return {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"username": target_user.username,
|
"username": target_user.username,
|
||||||
@@ -387,7 +439,44 @@ async def admin_get_user_rpu(
|
|||||||
"rpu_level": calculate_rpu_level(rpu),
|
"rpu_level": calculate_rpu_level(rpu),
|
||||||
"stats": {
|
"stats": {
|
||||||
"total_spent": rpu.total_spent,
|
"total_spent": rpu.total_spent,
|
||||||
"total_opened": rpu.total_opened
|
"total_opened": rpu.total_opened,
|
||||||
|
"total_value_received": rpu.total_value_received,
|
||||||
|
"current_streak": rpu.current_streak,
|
||||||
|
"best_streak": rpu.best_streak,
|
||||||
|
"worst_streak": rpu.worst_streak
|
||||||
|
},
|
||||||
|
# ── RPU v2 ──
|
||||||
|
"v2": {
|
||||||
|
"ceiling": {
|
||||||
|
"total_deposited": target_user.total_deposited or 0,
|
||||||
|
"ceiling_multiplier": rpu.ceiling_multiplier,
|
||||||
|
"current_ceiling": ceiling,
|
||||||
|
"break_count": rpu.ceiling_break_count,
|
||||||
|
"break_session": rpu.ceiling_break_session,
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"luck_budget": round(rpu.luck_budget, 1),
|
||||||
|
"session_spent": round(rpu.session_spent, 0),
|
||||||
|
"session_won": round(rpu.session_won, 0),
|
||||||
|
"reset_date": rpu.session_reset_date.isoformat() if rpu.session_reset_date else None,
|
||||||
|
},
|
||||||
|
"comeback": {
|
||||||
|
"active": rpu.comeback_active,
|
||||||
|
"openings_left": rpu.comeback_openings_left,
|
||||||
|
"multiplier": rpu.comeback_multiplier,
|
||||||
|
"consecutive_loss_value": round(rpu.consecutive_loss_value or 0, 0),
|
||||||
|
"consecutive_loss_count": rpu.consecutive_loss_count or 0,
|
||||||
|
},
|
||||||
|
"hot_cold": {
|
||||||
|
"hot_score": round(rpu.hot_score or 0, 2),
|
||||||
|
"last_activity": rpu.last_activity_date.isoformat() if rpu.last_activity_date else None,
|
||||||
|
},
|
||||||
|
"promo_phase": {
|
||||||
|
"phase": promo["phase"],
|
||||||
|
"effect": promo["effect"],
|
||||||
|
"next_switch": promo["next_switch"],
|
||||||
|
"remaining_minutes": promo["remaining_minutes"],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,30 +570,30 @@ async def admin_set_rpu_preset(
|
|||||||
|
|
||||||
@admin_router.post("/api/cases/create")
|
@admin_router.post("/api/cases/create")
|
||||||
async def admin_create_case(
|
async def admin_create_case(
|
||||||
name: str = Form(...),
|
case_name: str = Form(...),
|
||||||
display_name: str = Form(...),
|
display_name: str = Form(...),
|
||||||
price: float = Form(...),
|
price_open: float = Form(...),
|
||||||
description: str = Form(""),
|
description: str = Form(""),
|
||||||
image_url: str = Form(""),
|
image_url: str = Form(""),
|
||||||
items: str = Form(...),
|
items_json: str = Form(...),
|
||||||
admin: User = Depends(get_current_admin)
|
admin: User = Depends(get_current_admin)
|
||||||
):
|
):
|
||||||
cases = load_cases_config()
|
cases = load_cases_config()
|
||||||
|
|
||||||
for case in cases:
|
for case in cases:
|
||||||
if case['name'] == name:
|
if case['name'] == case_name:
|
||||||
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
|
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item_ids = json.loads(items)
|
parsed_items = json.loads(items_json)
|
||||||
except:
|
except:
|
||||||
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
|
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
|
||||||
|
|
||||||
new_case = {
|
new_case = {
|
||||||
"name": name,
|
"name": case_name,
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"price_open": price,
|
"price_open": price_open,
|
||||||
"items": item_ids,
|
"items": parsed_items,
|
||||||
"image_url": image_url,
|
"image_url": image_url,
|
||||||
"description": description
|
"description": description
|
||||||
}
|
}
|
||||||
@@ -522,10 +611,11 @@ async def admin_create_case(
|
|||||||
async def admin_update_case(
|
async def admin_update_case(
|
||||||
case_name: str,
|
case_name: str,
|
||||||
display_name: str = Form(...),
|
display_name: str = Form(...),
|
||||||
price: float = Form(...),
|
price_open: float = Form(...),
|
||||||
description: str = Form(""),
|
description: str = Form(""),
|
||||||
image_url: str = Form(""),
|
image_url: str = Form(""),
|
||||||
items: str = Form(...),
|
items_json: str = Form(...),
|
||||||
|
new_name: str = Form(""),
|
||||||
admin: User = Depends(get_current_admin)
|
admin: User = Depends(get_current_admin)
|
||||||
):
|
):
|
||||||
cases = load_cases_config()
|
cases = load_cases_config()
|
||||||
@@ -533,18 +623,34 @@ async def admin_update_case(
|
|||||||
for case in cases:
|
for case in cases:
|
||||||
if case['name'] == case_name:
|
if case['name'] == case_name:
|
||||||
try:
|
try:
|
||||||
item_ids = json.loads(items)
|
parsed_items = json.loads(items_json)
|
||||||
except:
|
except:
|
||||||
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
|
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['display_name'] = display_name
|
||||||
case['price_open'] = price
|
case['price_open'] = price_open
|
||||||
case['description'] = description
|
case['description'] = description
|
||||||
case['image_url'] = image_url
|
case['image_url'] = image_url
|
||||||
case['items'] = item_ids
|
case['items'] = parsed_items
|
||||||
|
|
||||||
save_cases_config(cases)
|
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({
|
return JSONResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
"case": case,
|
"case": case,
|
||||||
@@ -594,57 +700,90 @@ async def admin_search_items(
|
|||||||
q: str = "",
|
q: str = "",
|
||||||
min_price: float = 0,
|
min_price: float = 0,
|
||||||
max_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 frontend import ALL_ITEMS
|
||||||
|
from backend import COLLECTIONS_INDEX
|
||||||
|
|
||||||
query = q.lower().strip()
|
query = q.lower().strip()
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
seen_bases = set()
|
||||||
|
|
||||||
def accept_item(item, idx):
|
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:
|
if min_price > 0 and item.get("price_rub", 0) < min_price:
|
||||||
return False
|
return False
|
||||||
if max_price > 0 and item.get("price_rub", 0) > max_price:
|
if max_price > 0 and item.get("price_rub", 0) > max_price:
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
if not query or len(query) < 2:
|
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 min_price > 0 or max_price > 0:
|
if dedup_key in seen_bases:
|
||||||
for idx, item in enumerate(ALL_ITEMS):
|
return None
|
||||||
if accept_item(item, idx):
|
seen_bases.add(dedup_key)
|
||||||
item_id = item.get("_id", idx)
|
return {
|
||||||
results.append({
|
"id": item.get("_id", idx),
|
||||||
"id": item_id,
|
"name": name,
|
||||||
"name": item.get("market_hash_name", "Unknown"),
|
|
||||||
"rarity": item.get("rarity", "Unknown"),
|
"rarity": item.get("rarity", "Unknown"),
|
||||||
"image_url": item.get("image_url", ""),
|
"image_url": item.get("image_url", ""),
|
||||||
"price_rub": item.get("price_rub", 0),
|
"price_rub": item.get("price_rub", 0),
|
||||||
"weapon": item.get("weapon", ""),
|
"weapon": item.get("weapon", ""),
|
||||||
"pattern": item.get("pattern", "")
|
"pattern": item.get("pattern", ""),
|
||||||
})
|
"collection": item.get("collection", ""),
|
||||||
|
"type": item.get("type", "Normal"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if query and len(query) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
if any([min_price > 0, max_price > 0, rarity_filter, collection_filter,
|
||||||
|
type_filter, weapon_filter]):
|
||||||
|
for idx, item in enumerate(ALL_ITEMS):
|
||||||
|
if not accept_item(item, idx):
|
||||||
|
continue
|
||||||
|
r = make_result(item, idx)
|
||||||
|
if r:
|
||||||
|
results.append(r)
|
||||||
if len(results) >= limit:
|
if len(results) >= limit:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
step = max(1, len(ALL_ITEMS) // limit)
|
step = max(1, len(ALL_ITEMS) // limit)
|
||||||
for idx in range(0, len(ALL_ITEMS), step):
|
for idx in range(0, len(ALL_ITEMS), step):
|
||||||
if not accept_item(ALL_ITEMS[idx], idx):
|
|
||||||
continue
|
|
||||||
item = ALL_ITEMS[idx]
|
item = ALL_ITEMS[idx]
|
||||||
item_id = item.get("_id", idx)
|
if not accept_item(item, idx):
|
||||||
results.append({
|
continue
|
||||||
"id": item_id,
|
r = make_result(item, idx)
|
||||||
"name": item.get("market_hash_name", "Unknown"),
|
if r:
|
||||||
"rarity": item.get("rarity", "Unknown"),
|
results.append(r)
|
||||||
"image_url": item.get("image_url", ""),
|
if len(results) >= limit:
|
||||||
"price_rub": item.get("price_rub", 0),
|
|
||||||
"weapon": item.get("weapon", ""),
|
|
||||||
"pattern": item.get("pattern", "")
|
|
||||||
})
|
|
||||||
if len(results) >= 200:
|
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
for idx, item in enumerate(ALL_ITEMS):
|
for idx, item in enumerate(ALL_ITEMS):
|
||||||
@@ -653,22 +792,111 @@ async def admin_search_items(
|
|||||||
name = item.get("market_hash_name", "").lower()
|
name = item.get("market_hash_name", "").lower()
|
||||||
weapon = item.get("weapon", "").lower()
|
weapon = item.get("weapon", "").lower()
|
||||||
pattern = item.get("pattern", "").lower()
|
pattern = item.get("pattern", "").lower()
|
||||||
|
coll = item.get("collection", "").lower()
|
||||||
|
|
||||||
if query in name or query in weapon or query in pattern:
|
if query in name or query in weapon or query in pattern or query in coll:
|
||||||
item_id = item.get("_id", idx)
|
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({
|
results.append({
|
||||||
"id": item_id,
|
"id": item.get("_id", idx),
|
||||||
"name": item.get("market_hash_name", "Unknown"),
|
"name": name,
|
||||||
"rarity": item.get("rarity", "Unknown"),
|
"rarity": item.get("rarity", "Unknown"),
|
||||||
"image_url": item.get("image_url", ""),
|
"image_url": item.get("image_url", ""),
|
||||||
"price_rub": item.get("price_rub", 0),
|
"price_rub": item.get("price_rub", 0),
|
||||||
"weapon": item.get("weapon", ""),
|
"weapon": item.get("weapon", ""),
|
||||||
"pattern": item.get("pattern", "")
|
"pattern": item.get("pattern", ""),
|
||||||
|
"collection": item.get("collection", ""),
|
||||||
|
"type": item.get("type", "Normal"),
|
||||||
})
|
})
|
||||||
if len(results) >= 200:
|
return results
|
||||||
break
|
|
||||||
|
|
||||||
return results[:200]
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/api/mainpage/section/add")
|
@admin_router.post("/api/mainpage/section/add")
|
||||||
@@ -787,6 +1015,129 @@ async def admin_get_item(
|
|||||||
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
|
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}")
|
@admin_router.post("/api/reset-password/{user_id}")
|
||||||
async def admin_reset_password(
|
async def admin_reset_password(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -3,127 +3,135 @@ from typing import Optional
|
|||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from fastapi import Depends, HTTPException, status, Request
|
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 sqlalchemy.orm import Session
|
||||||
from database import get_db, User
|
from database import get_async_db, get_db, User
|
||||||
import os
|
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"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Optional[dict]:
|
def decode_token(token: str) -> Optional[dict]:
|
||||||
"""Декодирует JWT токен"""
|
if not token:
|
||||||
if not token: # <-- Добавлена проверка
|
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
return payload
|
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
def get_password_hash(password: str) -> str:
|
||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||||
expire = datetime.utcnow() + expires_delta
|
|
||||||
else:
|
|
||||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
async def get_current_user_from_token(token: str, db: Session) -> Optional[User]:
|
|
||||||
"""Получает пользователя из JWT токена"""
|
async def get_current_user_from_token(token: str, db) -> Optional[User]:
|
||||||
if not token: # <-- Добавлена проверка
|
if not token:
|
||||||
return None
|
return None
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
if not username:
|
if not username:
|
||||||
return None
|
return None
|
||||||
|
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()
|
||||||
|
|
||||||
user = db.query(User).filter(User.username == username).first()
|
|
||||||
return user
|
|
||||||
|
|
||||||
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")
|
token = request.cookies.get("access_token")
|
||||||
if not token: # <-- Исправлено: сразу возвращаем None
|
if not token:
|
||||||
return None
|
return None
|
||||||
if token.startswith("Bearer "):
|
if token.startswith("Bearer "):
|
||||||
token = token[7:]
|
token = token[7:]
|
||||||
return await get_current_user_from_token(token, db)
|
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")
|
token = request.cookies.get("access_token")
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Not authenticated",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if token.startswith("Bearer "):
|
if token.startswith("Bearer "):
|
||||||
token = token[7:]
|
token = token[7:]
|
||||||
|
|
||||||
user = await get_current_user_from_token(token, db)
|
user = await get_current_user_from_token(token, db)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid authentication credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
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:
|
def create_user(db: Session, username: str, password: str) -> User:
|
||||||
hashed_password = get_password_hash(password)
|
hashed = get_password_hash(password)
|
||||||
user = User(username=username, hashed_password=hashed_password, balance=1000.0)
|
user = User(username=username, hashed_password=hashed, balance=1000.0)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
||||||
user = db.query(User).filter(User.username == username).first()
|
user = db.query(User).filter(User.username == username).first()
|
||||||
if not user:
|
if not user or not verify_password(password, user.hashed_password):
|
||||||
return None
|
|
||||||
if not verify_password(password, user.hashed_password):
|
|
||||||
return None
|
return None
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def is_item_craftable(item_id: int) -> bool:
|
|
||||||
"""Проверяет, можно ли использовать предмет в контракте"""
|
|
||||||
from backend import get_item, is_final_in_collection
|
|
||||||
|
|
||||||
|
def is_item_craftable(item_id: int) -> bool:
|
||||||
|
from backend import get_item, is_final_in_collection
|
||||||
item = get_item(item_id)
|
item = get_item(item_id)
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not item.get("is_craftable", True):
|
if not item.get("is_craftable", True):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if is_final_in_collection(item):
|
if is_final_in_collection(item):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
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
|
|
||||||
+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
|
||||||
+1891
-1
File diff suppressed because it is too large
Load Diff
+224
-70
@@ -1,19 +1,59 @@
|
|||||||
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text
|
import os
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import sessionmaker, relationship
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import AsyncGenerator, Generator, Optional
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///./cs2_simulator.db"
|
from sqlalchemy import (
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
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)
|
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):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_users_username", "username"),
|
||||||
|
Index("ix_users_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
username = Column(String(50), unique=True, nullable=False)
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
hashed_password = Column(String(200), nullable=False)
|
hashed_password = Column(String(200), nullable=False)
|
||||||
balance = Column(Float, default=0.0)
|
balance = Column(Float, default=0.0)
|
||||||
@@ -21,6 +61,10 @@ class User(Base):
|
|||||||
is_banned = Column(Boolean, default=False)
|
is_banned = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
last_login = Column(DateTime, nullable=True)
|
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")
|
inventory_items = relationship("InventoryItem", back_populates="user", cascade="all, delete-orphan")
|
||||||
case_openings = relationship("CaseOpening", back_populates="user", cascade="all, delete-orphan")
|
case_openings = relationship("CaseOpening", back_populates="user", cascade="all, delete-orphan")
|
||||||
@@ -28,34 +72,34 @@ class User(Base):
|
|||||||
rpu_settings = relationship("UserRPU", back_populates="user", uselist=False, 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")
|
upgrades = relationship("Upgrade", back_populates="user", cascade="all, delete-orphan")
|
||||||
upgrade_rpu = relationship("UpgradeRPU", back_populates="user", uselist=False, 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):
|
class UpgradeRPU(Base):
|
||||||
__tablename__ = "upgrade_rpu"
|
__tablename__ = "upgrade_rpu"
|
||||||
|
__table_args__ = (Index("ix_upgrade_rpu_user_id", "user_id", unique=True),)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||||
|
|
||||||
# Множитель шанса апгрейда (1.0 = стандартный)
|
|
||||||
upgrade_multiplier = Column(Float, default=1.0)
|
upgrade_multiplier = Column(Float, default=1.0)
|
||||||
|
|
||||||
# Автоматическая подкрутка
|
|
||||||
auto_adjust = Column(Boolean, default=False)
|
auto_adjust = Column(Boolean, default=False)
|
||||||
|
|
||||||
# Статистика
|
|
||||||
total_attempts = Column(Integer, default=0)
|
total_attempts = Column(Integer, default=0)
|
||||||
total_success = 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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
user = relationship("User", back_populates="upgrade_rpu")
|
user = relationship("User", back_populates="upgrade_rpu")
|
||||||
|
|
||||||
|
|
||||||
class Upgrade(Base):
|
class Upgrade(Base):
|
||||||
__tablename__ = "upgrades"
|
__tablename__ = "upgrades"
|
||||||
|
__table_args__ = (Index("ix_upgrades_user_id_created", "user_id", "created_at"),)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
input_item_id = Column(Integer, nullable=False)
|
input_item_id = Column(Integer, nullable=False)
|
||||||
input_item_name = Column(String(200))
|
input_item_name = Column(String(200))
|
||||||
@@ -64,19 +108,18 @@ class Upgrade(Base):
|
|||||||
target_item_name = Column(String(200))
|
target_item_name = Column(String(200))
|
||||||
target_item_image_url = Column(String(500), default="")
|
target_item_image_url = Column(String(500), default="")
|
||||||
success = Column(Boolean, default=False)
|
success = Column(Boolean, default=False)
|
||||||
probability = Column(Float) # Базовый шанс
|
probability = Column(Float)
|
||||||
rpu_adjusted_probability = Column(Float) # Шанс с учетом РПУ
|
rpu_adjusted_probability = Column(Float)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
user = relationship("User", back_populates="upgrades")
|
user = relationship("User", back_populates="upgrades")
|
||||||
|
|
||||||
|
|
||||||
class UserRPU(Base):
|
class UserRPU(Base):
|
||||||
__tablename__ = "user_rpu"
|
__tablename__ = "user_rpu"
|
||||||
|
__table_args__ = (Index("ix_user_rpu_user_id", "user_id", unique=True),)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||||
|
|
||||||
# Множители шансов для каждой редкости (1.0 = стандартный шанс)
|
|
||||||
consumer_multiplier = Column(Float, default=1.0)
|
consumer_multiplier = Column(Float, default=1.0)
|
||||||
industrial_multiplier = Column(Float, default=1.0)
|
industrial_multiplier = Column(Float, default=1.0)
|
||||||
mil_spec_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)
|
classified_multiplier = Column(Float, default=1.0)
|
||||||
covert_multiplier = Column(Float, default=1.0)
|
covert_multiplier = Column(Float, default=1.0)
|
||||||
rare_special_multiplier = Column(Float, default=1.0)
|
rare_special_multiplier = Column(Float, default=1.0)
|
||||||
|
|
||||||
# Общий множитель удачи
|
|
||||||
luck_multiplier = Column(Float, default=1.0)
|
luck_multiplier = Column(Float, default=1.0)
|
||||||
|
|
||||||
# Автоматическая подкрутка
|
|
||||||
auto_adjust = Column(Boolean, default=False)
|
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_value_received = Column(Float, default=0.0)
|
||||||
total_opened = Column(Integer, default=0) # Всего открыто кейсов
|
|
||||||
last_adjustment = Column(DateTime, nullable=True)
|
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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
user = relationship("User", back_populates="rpu_settings")
|
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):
|
class InventoryItem(Base):
|
||||||
__tablename__ = "inventory_items"
|
__tablename__ = "inventory_items"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_inventory_user_id", "user_id"),
|
||||||
|
Index("ix_inventory_user_obtained", "user_id", "obtained_at"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
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)
|
market_hash_name = Column(String(200), nullable=False)
|
||||||
rarity = Column(String(50))
|
rarity = Column(String(50))
|
||||||
wear = Column(String(50))
|
wear = Column(String(50))
|
||||||
float_value = Column(Float, default=0.0)
|
float_value = Column(Float, default=0.0)
|
||||||
type = Column(String(20), default="Normal")
|
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)
|
obtained_at = Column(DateTime, default=datetime.utcnow)
|
||||||
is_equipped = Column(Boolean, default=False)
|
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")
|
user = relationship("User", back_populates="inventory_items")
|
||||||
|
|
||||||
|
|
||||||
class CaseOpening(Base):
|
class CaseOpening(Base):
|
||||||
__tablename__ = "case_openings"
|
__tablename__ = "case_openings"
|
||||||
|
__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, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
case_name = Column(String(100), nullable=False)
|
case_name = Column(String(100), nullable=False)
|
||||||
item_id = Column(Integer, nullable=False)
|
item_id = Column(Integer, nullable=False)
|
||||||
@@ -131,87 +234,138 @@ class CaseOpening(Base):
|
|||||||
rarity = Column(String(50))
|
rarity = Column(String(50))
|
||||||
float_value = Column(Float)
|
float_value = Column(Float)
|
||||||
opened_at = Column(DateTime, default=datetime.utcnow)
|
opened_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
user = relationship("User", back_populates="case_openings")
|
user = relationship("User", back_populates="case_openings")
|
||||||
|
|
||||||
|
|
||||||
class Contract(Base):
|
class Contract(Base):
|
||||||
__tablename__ = "contracts"
|
__tablename__ = "contracts"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_contracts_user_id", "user_id"),
|
||||||
|
Index("ix_contracts_user_created", "user_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
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_id = Column(Integer, nullable=False)
|
||||||
output_item_name = Column(String(200))
|
output_item_name = Column(String(200))
|
||||||
output_float = Column(Float)
|
output_float = Column(Float)
|
||||||
probability = Column(Float)
|
probability = Column(Float)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
user = relationship("User", back_populates="contracts")
|
user = relationship("User", back_populates="contracts")
|
||||||
|
|
||||||
|
|
||||||
class Achievement(Base):
|
class Achievement(Base):
|
||||||
__tablename__ = "achievements"
|
__tablename__ = "achievements"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String(100), unique=True, nullable=False)
|
name = Column(String(100), unique=True, nullable=False)
|
||||||
title = Column(String(200), nullable=False)
|
title = Column(String(200), nullable=False)
|
||||||
description = Column(String(500))
|
description = Column(String(500))
|
||||||
icon = Column(String(50), default="🏆")
|
icon = Column(String(50), default="🏆")
|
||||||
category = Column(String(50), default="general") # cases, contracts, upgrade, social, general
|
category = Column(String(50), default="general")
|
||||||
requirement_type = Column(String(50), nullable=False) # open_cases, contracts, upgrade_wins, total_spent, valuable_item, etc.
|
requirement_type = Column(String(50), nullable=False)
|
||||||
requirement_value = Column(Integer, nullable=False) # how many
|
requirement_value = Column(Integer, nullable=False)
|
||||||
reward_amount = Column(Float, default=0.0) # bonus rubles
|
reward_amount = Column(Float, default=0.0)
|
||||||
sort_order = Column(Integer, default=0)
|
sort_order = Column(Integer, default=0)
|
||||||
hidden = Column(Boolean, default=False) # секретные достижения
|
hidden = Column(Boolean, default=False)
|
||||||
|
|
||||||
user_achievements = relationship("UserAchievement", back_populates="achievement")
|
user_achievements = relationship("UserAchievement", back_populates="achievement")
|
||||||
|
|
||||||
|
|
||||||
class UserAchievement(Base):
|
class UserAchievement(Base):
|
||||||
__tablename__ = "user_achievements"
|
__tablename__ = "user_achievements"
|
||||||
|
__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, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
achievement_id = Column(Integer, ForeignKey("achievements.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)
|
unlocked_at = Column(DateTime, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
user = relationship("User", backref="user_achievements")
|
user = relationship("User", backref="user_achievements")
|
||||||
achievement = relationship("Achievement", back_populates="user_achievements")
|
achievement = relationship("Achievement", back_populates="user_achievements")
|
||||||
|
|
||||||
|
|
||||||
class ActivityFeed(Base):
|
class ActivityFeed(Base):
|
||||||
__tablename__ = "activity_feed"
|
__tablename__ = "activity_feed"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_activity_user_id", "user_id"),
|
||||||
|
Index("ix_activity_created", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
username = Column(String(50), 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)
|
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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
user = relationship("User", backref="activities")
|
user = relationship("User", backref="activities")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Create tables ──────────────────────────────────────────────────────────
|
||||||
|
def init_sync_db():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_run_migrations(engine, is_sync=True)
|
||||||
|
|
||||||
# Миграция: добавляем колонку hidden если её нет
|
|
||||||
|
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:
|
try:
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
inspector = inspect(engine)
|
inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn)
|
||||||
cols = [c["name"] for c in inspector.get_columns("achievements")]
|
existing_tables = inspector.get_table_names()
|
||||||
if "hidden" not in cols:
|
for tbl_name in Base.metadata.tables:
|
||||||
from sqlalchemy import text
|
if tbl_name not in existing_tables:
|
||||||
with engine.connect() as conn:
|
continue
|
||||||
conn.execute(text("ALTER TABLE achievements ADD COLUMN hidden BOOLEAN DEFAULT 0"))
|
model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns}
|
||||||
conn.commit()
|
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:
|
except Exception as e:
|
||||||
print(f"[DB] Миграция hidden: {e}")
|
print(f"[DB] Migration check: {e}")
|
||||||
|
|
||||||
def get_db():
|
|
||||||
|
# ─── Session helpers ────────────────────────────────────────────────────────
|
||||||
|
def get_db() -> Generator:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
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)
|
||||||
|
|||||||
+672
-380
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -12,18 +12,18 @@
|
|||||||
"cases": [
|
"cases": [
|
||||||
"CS:GO Weapon Case",
|
"CS:GO Weapon Case",
|
||||||
"Revolution Case",
|
"Revolution Case",
|
||||||
"Recoil Case"
|
"Recoil Case",
|
||||||
|
"Prisma 2 Case",
|
||||||
|
"Prisma Case",
|
||||||
|
"Fracture Case",
|
||||||
|
"Danger Zone Case"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tab": "Новинки",
|
"tab": "Новинки",
|
||||||
"cases": [
|
"cases": [
|
||||||
"allin1",
|
"allin1",
|
||||||
"Danger Zone Case",
|
"Revolvernoe"
|
||||||
"Prisma Case",
|
|
||||||
"Fracture Case",
|
|
||||||
"Prisma 2 Case",
|
|
||||||
"m9orbutterfly1"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
+3
-1
@@ -6,9 +6,11 @@ markupsafe>=3.0.0
|
|||||||
sqlalchemy>=2.0
|
sqlalchemy>=2.0
|
||||||
python-jose[cryptography]
|
python-jose[cryptography]
|
||||||
passlib
|
passlib
|
||||||
bcrypt
|
bcrypt>=4.0.0
|
||||||
python-multipart
|
python-multipart
|
||||||
python-dateutil
|
python-dateutil
|
||||||
aiofiles
|
aiofiles
|
||||||
tqdm
|
tqdm
|
||||||
aiosqlite
|
aiosqlite
|
||||||
|
asyncpg
|
||||||
|
redis>=5.0
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
|
import json
|
||||||
import random
|
import random
|
||||||
from datetime import datetime
|
import math
|
||||||
from typing import Dict, Optional
|
from datetime import datetime, date
|
||||||
from sqlalchemy.orm import Session
|
from typing import Dict, Optional, Tuple
|
||||||
from database import User, UserRPU
|
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 = {
|
BASE_RARITY_WEIGHTS = {
|
||||||
"Consumer Grade": 10000,
|
"Consumer Grade": 10000, "Industrial Grade": 5000,
|
||||||
"Industrial Grade": 5000,
|
"Mil-Spec": 2500, "Mil-Spec Grade": 2500,
|
||||||
"Mil-Spec": 2500,
|
"Restricted": 625, "Classified": 125,
|
||||||
"Mil-Spec Grade": 2500,
|
"Covert": 25, "Rare Special Item": 5, "Extraordinary": 5
|
||||||
"Restricted": 625,
|
|
||||||
"Classified": 125,
|
|
||||||
"Covert": 25,
|
|
||||||
"Rare Special Item": 5,
|
|
||||||
"Extraordinary": 5
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Маппинг редкостей на поля в UserRPU
|
|
||||||
RARITY_TO_FIELD = {
|
RARITY_TO_FIELD = {
|
||||||
"Consumer Grade": "consumer_multiplier",
|
"Consumer Grade": "consumer_multiplier",
|
||||||
"Industrial Grade": "industrial_multiplier",
|
"Industrial Grade": "industrial_multiplier",
|
||||||
@@ -30,163 +27,484 @@ RARITY_TO_FIELD = {
|
|||||||
"Extraordinary": "rare_special_multiplier"
|
"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:
|
CEILING_BASE_MULTIPLIER = 3.0
|
||||||
"""Получает или создает настройки РПУ для пользователя"""
|
CEILING_MIN = 1000.0
|
||||||
rpu = db.query(UserRPU).filter(UserRPU.user_id == user_id).first()
|
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:
|
if not rpu:
|
||||||
rpu = UserRPU(user_id=user_id)
|
rpu = UserRPU(user_id=user_id)
|
||||||
db.add(rpu)
|
db.add(rpu)
|
||||||
db.commit()
|
await _rpu_commit_or_flush_async(db)
|
||||||
db.refresh(rpu)
|
await db.refresh(rpu)
|
||||||
return rpu
|
return rpu
|
||||||
|
|
||||||
|
|
||||||
def get_adjusted_weights(db: Session, user_id: int) -> Dict[str, float]:
|
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 = get_user_rpu(db, user_id)
|
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)
|
||||||
|
|
||||||
# Вычисляем общий уровень РПУ (0-100%)
|
|
||||||
rpu_level = calculate_rpu_level(rpu)
|
|
||||||
|
|
||||||
# Переворачиваем: низкий РПУ = высокий множитель для редких
|
async def calculate_ceiling(db: AsyncSession, user_id: int) -> float:
|
||||||
# При 0% РПУ множитель для редких = 2.0 (очень везёт)
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
# При 50% РПУ множитель = 1.0 (обычно)
|
user = result.scalar_one_or_none()
|
||||||
# При 100% РПУ множитель = 0.1 (совсем не везёт)
|
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:
|
||||||
|
multipliers = [
|
||||||
|
rpu.consumer_multiplier, rpu.industrial_multiplier,
|
||||||
|
rpu.mil_spec_multiplier, rpu.restricted_multiplier,
|
||||||
|
rpu.classified_multiplier, rpu.covert_multiplier,
|
||||||
|
rpu.rare_special_multiplier
|
||||||
|
]
|
||||||
|
avg = sum(multipliers) / len(multipliers)
|
||||||
|
if avg >= 1.0:
|
||||||
|
level = 50 - ((avg - 1.0) / 2.0) * 100
|
||||||
|
else:
|
||||||
|
level = 50 + ((1.0 - avg) / 0.9) * 50
|
||||||
|
return max(0, min(100, level))
|
||||||
|
|
||||||
|
|
||||||
|
def get_rpu_description(level: float) -> str:
|
||||||
|
if level < 20: return "🍀 Очень везучий"
|
||||||
|
elif level < 35: return "😊 Везучий"
|
||||||
|
elif level < 45: return "🙂 Немного везучий"
|
||||||
|
elif level < 55: return "😐 Обычный"
|
||||||
|
elif level < 70: return "🙁 Немного невезучий"
|
||||||
|
elif level < 85: return "😟 Невезучий"
|
||||||
|
else: return "💀 Проклятый"
|
||||||
|
|
||||||
|
|
||||||
|
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 = {}
|
adjusted = {}
|
||||||
for rarity, base_weight in BASE_RARITY_WEIGHTS.items():
|
for rarity, base_weight in BASE_RARITY_WEIGHTS.items():
|
||||||
field = RARITY_TO_FIELD.get(rarity)
|
field = RARITY_TO_FIELD.get(rarity)
|
||||||
base_multiplier = getattr(rpu, field, 1.0) if field else 1.0
|
base_multiplier = getattr(rpu, field, 1.0) if field else 1.0
|
||||||
|
rarity_index = RARITY_RANK.get(rarity, 0)
|
||||||
|
|
||||||
# Определяем "редкость" предмета (чем реже, тем больше индекс)
|
if level < 50:
|
||||||
rarity_index = 0
|
luck_factor = 1.0 + ((50 - level) / 50) * rarity_index * 0.3
|
||||||
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:
|
else:
|
||||||
# Не везёт: понижаем шансы на редкое
|
luck_factor = 1.0 - ((level - 50) / 50) * rarity_index * 0.2
|
||||||
luck_factor = 1.0 - ((rpu_level - 50) / 50) * rarity_index * 0.2
|
|
||||||
|
|
||||||
luck_factor = max(0.1, min(3.0, luck_factor))
|
luck_factor = max(0.1, min(3.0, luck_factor))
|
||||||
|
|
||||||
final_multiplier = base_multiplier * rpu.luck_multiplier * luck_factor
|
final = base_multiplier * rpu.luck_multiplier * luck_factor
|
||||||
adjusted[rarity] = base_weight * final_multiplier
|
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
|
return adjusted
|
||||||
|
|
||||||
|
|
||||||
def calculate_rpu_level(rpu: UserRPU) -> float:
|
async def auto_adjust_rpu(db: AsyncSession, user_id: int):
|
||||||
"""
|
rpu = await get_user_rpu(db, user_id)
|
||||||
Вычисляет общий уровень РПУ (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.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 "💀 Проклятый"
|
|
||||||
|
|
||||||
|
|
||||||
def auto_adjust_rpu(db: Session, user_id: int):
|
|
||||||
"""Автоматическая подкрутка РПУ на основе статистики"""
|
|
||||||
rpu = get_user_rpu(db, user_id)
|
|
||||||
|
|
||||||
if not rpu.auto_adjust:
|
if not rpu.auto_adjust:
|
||||||
return
|
return
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Анализируем статистику
|
if rpu.total_opened > 0 and rpu.total_spent > 0:
|
||||||
if rpu.total_opened > 0:
|
roi = rpu.total_value_received / rpu.total_spent
|
||||||
avg_spent_per_case = rpu.total_spent / rpu.total_opened
|
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 rpu.total_spent > 10000 and rpu.total_opened > 20:
|
if streak <= -5:
|
||||||
# Снижаем РПУ на 5-15%
|
boost = min(0.3, abs(streak) * 0.03)
|
||||||
reduction = min(0.15, rpu.total_spent / 100000)
|
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
|
||||||
rpu.luck_multiplier = min(2.0, rpu.luck_multiplier + reduction)
|
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:
|
if rpu.total_opened > 50:
|
||||||
rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05)
|
rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05)
|
||||||
rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03)
|
rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03)
|
||||||
|
|
||||||
# Ограничиваем множители
|
|
||||||
for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier',
|
for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier',
|
||||||
'restricted_multiplier', 'classified_multiplier', 'covert_multiplier',
|
'restricted_multiplier', 'classified_multiplier', 'covert_multiplier',
|
||||||
'rare_special_multiplier']:
|
'rare_special_multiplier']:
|
||||||
value = getattr(rpu, field)
|
setattr(rpu, field, max(0.1, min(3.0, getattr(rpu, field))))
|
||||||
setattr(rpu, field, max(0.1, min(3.0, value)))
|
|
||||||
|
|
||||||
rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier))
|
rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier))
|
||||||
rpu.last_adjustment = datetime.utcnow()
|
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):
|
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 = get_user_rpu(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()
|
||||||
|
|
||||||
rpu.total_spent += spent
|
rpu.total_spent += spent
|
||||||
rpu.total_opened += opened
|
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:
|
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}"
|
||||||
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 res = await fetch('/web/api/user/balance');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.querySelectorAll('.user-balance').forEach(el => {
|
const el = document.getElementById('siteBalanceDisplay');
|
||||||
el.textContent = `${Math.floor(data.balance)} ₽`;
|
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) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -140,7 +141,7 @@ function showAchievementPopup(title, reward) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('globalPopupTitle').textContent = title;
|
document.getElementById('globalPopupTitle').textContent = title;
|
||||||
document.getElementById('globalPopupReward').textContent = reward ? `+${reward} ₽` : '';
|
document.getElementById('globalPopupReward').textContent = '';
|
||||||
popup.style.display = 'block';
|
popup.style.display = 'block';
|
||||||
|
|
||||||
if (window.SoundManager) SoundManager.achievement();
|
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);}
|
||||||
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 = {
|
const SoundManager = {
|
||||||
_ctx: null,
|
_ctx: null,
|
||||||
_enabled: true,
|
_enabled: true,
|
||||||
_volume: 0.3,
|
_volume: 0.3,
|
||||||
|
_activeNodes: new Set(),
|
||||||
|
_timeouts: [],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
try {
|
try {
|
||||||
@@ -25,10 +26,28 @@ const SoundManager = {
|
|||||||
return 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) {
|
_play(freq, duration, type = 'sine', volume = 1) {
|
||||||
if (!this._enabled || !this._ctx) return;
|
if (!this._enabled || !this._ctx) return;
|
||||||
if (this._ctx.state === 'suspended') this._ctx.resume();
|
if (this._ctx.state === 'suspended') this._ctx.resume();
|
||||||
|
|
||||||
const osc = this._ctx.createOscillator();
|
const osc = this._ctx.createOscillator();
|
||||||
const gain = this._ctx.createGain();
|
const gain = this._ctx.createGain();
|
||||||
osc.type = type;
|
osc.type = type;
|
||||||
@@ -39,12 +58,13 @@ const SoundManager = {
|
|||||||
gain.connect(this._ctx.destination);
|
gain.connect(this._ctx.destination);
|
||||||
osc.start();
|
osc.start();
|
||||||
osc.stop(this._ctx.currentTime + duration);
|
osc.stop(this._ctx.currentTime + duration);
|
||||||
|
this._trackNode(osc, duration);
|
||||||
|
this._trackNode(gain, duration);
|
||||||
},
|
},
|
||||||
|
|
||||||
_noise(duration, volume = 1) {
|
_noise(duration, volume = 1) {
|
||||||
if (!this._enabled || !this._ctx) return;
|
if (!this._enabled || !this._ctx) return;
|
||||||
if (this._ctx.state === 'suspended') this._ctx.resume();
|
if (this._ctx.state === 'suspended') this._ctx.resume();
|
||||||
|
|
||||||
const bufferSize = this._ctx.sampleRate * duration;
|
const bufferSize = this._ctx.sampleRate * duration;
|
||||||
const buffer = this._ctx.createBuffer(1, bufferSize, this._ctx.sampleRate);
|
const buffer = this._ctx.createBuffer(1, bufferSize, this._ctx.sampleRate);
|
||||||
const data = buffer.getChannelData(0);
|
const data = buffer.getChannelData(0);
|
||||||
@@ -59,91 +79,113 @@ const SoundManager = {
|
|||||||
source.connect(gain);
|
source.connect(gain);
|
||||||
gain.connect(this._ctx.destination);
|
gain.connect(this._ctx.destination);
|
||||||
source.start();
|
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() {
|
caseOpen() {
|
||||||
// Descending sweep with click
|
|
||||||
this._play(800, 0.1, 'square', 0.3);
|
this._play(800, 0.1, 'square', 0.3);
|
||||||
setTimeout(() => this._play(400, 0.15, 'sawtooth', 0.2), 80);
|
this._delay(() => this._play(400, 0.15, 'sawtooth', 0.2), 80);
|
||||||
setTimeout(() => this._play(200, 0.2, 'sine', 0.15), 180);
|
this._delay(() => this._play(200, 0.2, 'sine', 0.15), 180);
|
||||||
this._noise(0.05, 0.4);
|
this._noise(0.05, 0.4);
|
||||||
},
|
},
|
||||||
|
|
||||||
caseRare() {
|
caseRare() {
|
||||||
// Ascending bright chime for rare items
|
|
||||||
this._play(523, 0.15, 'sine', 0.3);
|
this._play(523, 0.15, 'sine', 0.3);
|
||||||
setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 100);
|
this._delay(() => this._play(659, 0.15, 'sine', 0.3), 100);
|
||||||
setTimeout(() => this._play(784, 0.15, 'sine', 0.3), 200);
|
this._delay(() => this._play(784, 0.15, 'sine', 0.3), 200);
|
||||||
setTimeout(() => this._play(1047, 0.3, 'sine', 0.4), 300);
|
this._delay(() => this._play(1047, 0.3, 'sine', 0.4), 300);
|
||||||
},
|
},
|
||||||
|
|
||||||
caseCovert() {
|
caseCovert() {
|
||||||
// Epic sound for covert/knife
|
|
||||||
this._play(392, 0.2, 'sawtooth', 0.3);
|
this._play(392, 0.2, 'sawtooth', 0.3);
|
||||||
setTimeout(() => this._play(523, 0.2, 'sawtooth', 0.3), 150);
|
this._delay(() => this._play(523, 0.2, 'sawtooth', 0.3), 150);
|
||||||
setTimeout(() => this._play(659, 0.2, 'sawtooth', 0.3), 300);
|
this._delay(() => this._play(659, 0.2, 'sawtooth', 0.3), 300);
|
||||||
setTimeout(() => this._play(784, 0.4, 'sine', 0.5), 450);
|
this._delay(() => this._play(784, 0.4, 'sine', 0.5), 450);
|
||||||
setTimeout(() => this._play(1047, 0.6, 'sine', 0.6), 600);
|
this._delay(() => this._play(1047, 0.6, 'sine', 0.6), 600);
|
||||||
},
|
},
|
||||||
|
|
||||||
slotSpin() {
|
slotSpin() {
|
||||||
// Ratcheting sound for slot reels
|
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
setTimeout(() => {
|
this._delay(() => {
|
||||||
this._play(300 + Math.random() * 400, 0.05, 'square', 0.15);
|
this._play(300 + Math.random() * 400, 0.05, 'square', 0.15);
|
||||||
}, i * 80);
|
}, i * 80);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
slotMatch() {
|
slotMatch() {
|
||||||
// Win jingle
|
|
||||||
this._play(523, 0.1, 'sine', 0.3);
|
this._play(523, 0.1, 'sine', 0.3);
|
||||||
setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 100);
|
this._delay(() => this._play(659, 0.1, 'sine', 0.3), 100);
|
||||||
setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200);
|
this._delay(() => this._play(784, 0.1, 'sine', 0.3), 200);
|
||||||
setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 300);
|
this._delay(() => this._play(1047, 0.4, 'sine', 0.5), 300);
|
||||||
},
|
},
|
||||||
|
|
||||||
contractSubmit() {
|
contractSubmit() {
|
||||||
// Mechanical sound
|
|
||||||
this._play(150, 0.3, 'sawtooth', 0.2);
|
this._play(150, 0.3, 'sawtooth', 0.2);
|
||||||
setTimeout(() => this._play(200, 0.2, 'square', 0.2), 200);
|
this._delay(() => this._play(200, 0.2, 'square', 0.2), 200);
|
||||||
setTimeout(() => this._play(250, 0.1, 'square', 0.15), 350);
|
this._delay(() => this._play(250, 0.1, 'square', 0.15), 350);
|
||||||
},
|
},
|
||||||
|
|
||||||
contractResult() {
|
contractResult() {
|
||||||
// Rising success
|
|
||||||
this._play(400, 0.15, 'sine', 0.3);
|
this._play(400, 0.15, 'sine', 0.3);
|
||||||
setTimeout(() => this._play(600, 0.15, 'sine', 0.3), 120);
|
this._delay(() => this._play(600, 0.15, 'sine', 0.3), 120);
|
||||||
setTimeout(() => this._play(800, 0.3, 'sine', 0.4), 240);
|
this._delay(() => this._play(800, 0.3, 'sine', 0.4), 240);
|
||||||
},
|
},
|
||||||
|
|
||||||
wheelSpin() {
|
wheelSpin() {
|
||||||
// Ticking as wheel rotates
|
this._play(600 + Math.random() * 400, 0.04, 'triangle', 0.12);
|
||||||
this._play(200, 0.03, 'square', 0.1);
|
this._play(200, 0.02, 'square', 0.06);
|
||||||
},
|
},
|
||||||
|
|
||||||
wheelWin() {
|
wheelWin() {
|
||||||
// Celebration
|
const t = this._ctx.currentTime;
|
||||||
this._play(523, 0.15, 'sine', 0.3);
|
if (!this._enabled || !this._ctx) return;
|
||||||
setTimeout(() => this._play(659, 0.15, 'sine', 0.3), 120);
|
if (this._ctx.state === 'suspended') this._ctx.resume();
|
||||||
setTimeout(() => this._play(784, 0.2, 'sine', 0.35), 240);
|
[523, 659, 784, 1047].forEach((freq, i) => {
|
||||||
setTimeout(() => this._play(1047, 0.5, 'sine', 0.5), 360);
|
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() {
|
wheelLose() {
|
||||||
// Sad trombone
|
this._play(300, 0.15, 'sawtooth', 0.25);
|
||||||
this._play(400, 0.2, 'sine', 0.2);
|
this._delay(() => this._play(200, 0.2, 'sawtooth', 0.2), 100);
|
||||||
setTimeout(() => this._play(350, 0.2, 'sine', 0.2), 150);
|
this._delay(() => this._play(120, 0.3, 'sawtooth', 0.15), 220);
|
||||||
setTimeout(() => this._play(300, 0.3, 'sine', 0.15), 300);
|
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() {
|
||||||
// Achievement unlock
|
|
||||||
this._play(659, 0.1, 'sine', 0.3);
|
this._play(659, 0.1, 'sine', 0.3);
|
||||||
setTimeout(() => this._play(523, 0.1, 'sine', 0.3), 100);
|
this._delay(() => this._play(523, 0.1, 'sine', 0.3), 100);
|
||||||
setTimeout(() => this._play(784, 0.1, 'sine', 0.3), 200);
|
this._delay(() => this._play(784, 0.1, 'sine', 0.3), 200);
|
||||||
setTimeout(() => this._play(659, 0.1, 'sine', 0.3), 300);
|
this._delay(() => this._play(659, 0.1, 'sine', 0.3), 300);
|
||||||
setTimeout(() => this._play(1047, 0.4, 'sine', 0.5), 400);
|
this._delay(() => this._play(1047, 0.4, 'sine', 0.5), 400);
|
||||||
},
|
},
|
||||||
|
|
||||||
crashBet() {
|
crashBet() {
|
||||||
@@ -155,17 +197,15 @@ const SoundManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
crashCrashed() {
|
crashCrashed() {
|
||||||
// Explosion
|
|
||||||
this._noise(0.3, 0.6);
|
this._noise(0.3, 0.6);
|
||||||
this._play(80, 0.5, 'sawtooth', 0.4);
|
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() {
|
crashCashout() {
|
||||||
// Cashout success
|
|
||||||
this._play(600, 0.1, 'sine', 0.3);
|
this._play(600, 0.1, 'sine', 0.3);
|
||||||
setTimeout(() => this._play(800, 0.1, 'sine', 0.3), 80);
|
this._delay(() => this._play(800, 0.1, 'sine', 0.3), 80);
|
||||||
setTimeout(() => this._play(1000, 0.2, 'sine', 0.4), 160);
|
this._delay(() => this._play(1000, 0.2, 'sine', 0.4), 160);
|
||||||
},
|
},
|
||||||
|
|
||||||
click() {
|
click() {
|
||||||
@@ -181,5 +221,4 @@ const SoundManager = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-init
|
|
||||||
document.addEventListener('DOMContentLoaded', () => SoundManager.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); }
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
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 {
|
.has-activity-sidebar {
|
||||||
padding-left: 260px;
|
padding-left: 260px;
|
||||||
transition: padding-left 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
|
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" id="activitySidebar">
|
||||||
<div class="activity-sidebar-header">
|
<div class="activity-sidebar-header">
|
||||||
<h3><span class="live-dot"></span> Лента</h3>
|
<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>
|
<button class="activity-sidebar-close" onclick="toggleActivitySidebar()" title="Скрыть">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-sidebar-list" id="activitySidebarList">
|
<div class="activity-sidebar-list" id="activitySidebarList">
|
||||||
@@ -198,11 +245,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
WS.on('online_count', (data) => {
|
|
||||||
const el = document.getElementById('onlineCount');
|
|
||||||
if (el) el.textContent = data.online || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
WS.on('activity', (data) => {
|
WS.on('activity', (data) => {
|
||||||
const act = data.activity;
|
const act = data.activity;
|
||||||
if (!act || !list) return;
|
if (!act || !list) return;
|
||||||
@@ -241,9 +283,33 @@ function createActivityItem(act) {
|
|||||||
|
|
||||||
const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, '');
|
const raritySlug = (act.data && act.data.rarity || '').toLowerCase().replace(/ /g, '-').replace(/_/g, '-').replace(/™/g, '');
|
||||||
const rarityColor = ACTIVITY_RARITY_COLORS[raritySlug] || '';
|
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) {
|
if (rarityColor) {
|
||||||
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
|
item.style.background = `rgba(${hexToRgb(rarityColor)}, 0.08)`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item.addEventListener('click', () => window.location.href = `/profiles/${act.user_id}`);
|
item.addEventListener('click', () => window.location.href = `/profiles/${act.user_id}`);
|
||||||
|
|
||||||
@@ -260,6 +326,7 @@ function createActivityItem(act) {
|
|||||||
<span class="asi-username">${escapeHtml(act.username)}</span>
|
<span class="asi-username">${escapeHtml(act.username)}</span>
|
||||||
<span>${escapeHtml(act.message)}</span>
|
<span>${escapeHtml(act.message)}</span>
|
||||||
<div class="asi-time">${timeStr}</div>
|
<div class="asi-time">${timeStr}</div>
|
||||||
|
${effectBadgeHtml}
|
||||||
</div>
|
</div>
|
||||||
${imgHtml}
|
${imgHtml}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Достижения - CS2 Simulator</title>
|
<title>Достижения - CS2 Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
<style>
|
<style>
|
||||||
.achievement-card.secret {
|
.achievement-card.secret {
|
||||||
border-color: #8b5cf6;
|
border-color: #8b5cf6;
|
||||||
@@ -257,9 +257,6 @@
|
|||||||
{% if ach.hidden %}<div class="secret-badge">🤫 Секрет</div>{% endif %}
|
{% if ach.hidden %}<div class="secret-badge">🤫 Секрет</div>{% endif %}
|
||||||
<div class="achievement-title">{{ ach.title }}</div>
|
<div class="achievement-title">{{ ach.title }}</div>
|
||||||
<div class="achievement-desc">{{ ach.description }}</div>
|
<div class="achievement-desc">{{ ach.description }}</div>
|
||||||
{% if ach.reward_amount > 0 %}
|
|
||||||
<div class="achievement-reward">+{{ ach.reward_amount|money }} ₽</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="achievement-progress">
|
<div class="achievement-progress">
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-fill {{ 'complete' if ach.unlocked else '' }}"
|
<div class="progress-fill {{ 'complete' if ach.unlocked else '' }}"
|
||||||
@@ -286,9 +283,9 @@
|
|||||||
<div class="popup-reward" id="popupReward"></div>
|
<div class="popup-reward" id="popupReward"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/sounds.js"></script>
|
<script src="/static/js/sounds.min.js"></script>
|
||||||
<script src="/static/js/websocket.js"></script>
|
<script src="/static/js/websocket.min.js"></script>
|
||||||
<script src="/static/js/notifications.js"></script>
|
<script src="/static/js/notifications.min.js"></script>
|
||||||
<script src="/static/js/safemode.js"></script>
|
<script src="/static/js/safemode.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -308,7 +305,7 @@
|
|||||||
function showAchievementPopup(title, reward) {
|
function showAchievementPopup(title, reward) {
|
||||||
const popup = document.getElementById('achievementPopup');
|
const popup = document.getElementById('achievementPopup');
|
||||||
document.getElementById('popupTitle').textContent = title;
|
document.getElementById('popupTitle').textContent = title;
|
||||||
document.getElementById('popupReward').textContent = reward ? `+${reward} ₽` : '';
|
document.getElementById('popupReward').textContent = '';
|
||||||
popup.style.display = 'block';
|
popup.style.display = 'block';
|
||||||
SoundManager.achievement();
|
SoundManager.achievement();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Лента активностей - CS2 Simulator</title>
|
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
|
||||||
<style>
|
|
||||||
.activity-feed {
|
|
||||||
max-width: 700px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
transition: all 0.3s;
|
|
||||||
animation: fadeInUp 0.3s ease;
|
|
||||||
}
|
|
||||||
.activity-item:hover {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
.activity-icon {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg-dark);
|
|
||||||
border-radius: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.activity-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.activity-message {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.activity-meta {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
.activity-username {
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.activity-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.activity-empty .big-icon {
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.activity-type-icon {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 0.3rem;
|
|
||||||
}
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.activity-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.live-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--success-color);
|
|
||||||
}
|
|
||||||
.live-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background: var(--success-color);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse 1.5s ease infinite;
|
|
||||||
}
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% include "_nav.html" %}
|
|
||||||
|
|
||||||
<main class="container" style="padding: 2rem 1rem;">
|
|
||||||
<div class="activity-header">
|
|
||||||
<div>
|
|
||||||
<h1>📰 Лента активностей</h1>
|
|
||||||
<p class="page-subtitle">Что происходит на сервере</p>
|
|
||||||
</div>
|
|
||||||
<div class="live-indicator" id="liveIndicator">
|
|
||||||
<span class="live-dot"></span>
|
|
||||||
<span>Live</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="activity-feed" id="activityFeed">
|
|
||||||
{% if activities %}
|
|
||||||
{% for a in activities %}
|
|
||||||
<div class="activity-item" data-activity-id="{{ a.id }}">
|
|
||||||
<div class="activity-icon">
|
|
||||||
{% if a.activity_type == 'case_open' %}📦
|
|
||||||
{% elif a.activity_type == 'contract' %}🔄
|
|
||||||
{% elif a.activity_type == 'upgrade' %}🆙
|
|
||||||
{% elif a.activity_type == 'achievement' %}🏆
|
|
||||||
{% elif a.activity_type == 'crash' %}💥
|
|
||||||
{% else %}📌{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<div class="activity-message">{{ a.message }}</div>
|
|
||||||
<div class="activity-meta">
|
|
||||||
<a href="/profiles/{{ a.user_id }}" class="activity-username" style="text-decoration:none;">{{ a.username }}</a>
|
|
||||||
<span>• {{ a.created_at }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="activity-empty">
|
|
||||||
<div class="big-icon">📭</div>
|
|
||||||
<h3>Пока нет активностей</h3>
|
|
||||||
<p>Открой кейс или выполни контракт — это появится здесь в реальном времени!</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/static/js/sounds.js"></script>
|
|
||||||
<script src="/static/js/websocket.js"></script>
|
|
||||||
<script src="/static/js/notifications.js"></script>
|
|
||||||
<script src="/static/js/safemode.js"></script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
WS.on('activity', (data) => {
|
|
||||||
const act = data.activity;
|
|
||||||
if (!act) return;
|
|
||||||
|
|
||||||
const feed = document.getElementById('activityFeed');
|
|
||||||
|
|
||||||
const empty = feed.querySelector('.activity-empty');
|
|
||||||
if (empty) empty.remove();
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
'case_open': '📦',
|
|
||||||
'contract': '🔄',
|
|
||||||
'upgrade': '🆙',
|
|
||||||
'achievement': '🏆',
|
|
||||||
'crash': '💥'
|
|
||||||
};
|
|
||||||
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'activity-item';
|
|
||||||
item.style.animation = 'none';
|
|
||||||
item.offsetHeight;
|
|
||||||
item.style.animation = 'fadeInUp 0.3s ease';
|
|
||||||
item.innerHTML = `
|
|
||||||
<div class="activity-icon">${iconMap[act.activity_type] || '📌'}</div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<div class="activity-message">${act.message}</div>
|
|
||||||
<div class="activity-meta">
|
|
||||||
<a href="/profiles/${act.user_id || 0}" class="activity-username" style="text-decoration:none;">${escapeHtml(act.username)}</a>
|
|
||||||
<span>• только что</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
feed.insertBefore(item, feed.firstChild);
|
|
||||||
|
|
||||||
while (feed.children.length > 100) {
|
|
||||||
feed.removeChild(feed.lastChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (act.activity_type === 'achievement') {
|
|
||||||
SoundManager.achievement();
|
|
||||||
} else if (act.activity_type === 'crash') {
|
|
||||||
SoundManager.crashCashout();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
function toggleSound() {
|
|
||||||
const enabled = SoundManager.toggle();
|
|
||||||
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const btn = document.getElementById('soundToggle');
|
|
||||||
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+153
-11
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Админ-панель{% endblock %} — CS2 Simulator</title>
|
<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>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html { font-size: 15px; }
|
html { font-size: 15px; }
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: sticky; top: 0; height: 100vh;
|
position: sticky; top: 0; height: 100vh;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
z-index: 50;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
}
|
}
|
||||||
.admin-sidebar .sb-brand {
|
.admin-sidebar .sb-brand {
|
||||||
padding: 1.25rem 1.25rem 0.75rem;
|
padding: 1.25rem 1.25rem 0.75rem;
|
||||||
@@ -73,6 +75,28 @@
|
|||||||
}
|
}
|
||||||
.admin-sidebar .sb-footer a:hover { color: #f59e0b; }
|
.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 ─── */
|
/* ─── Main area ─── */
|
||||||
.admin-main {
|
.admin-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -85,8 +109,10 @@
|
|||||||
padding: 0.85rem 1.5rem;
|
padding: 0.85rem 1.5rem;
|
||||||
background: rgba(17,18,22,0.8);
|
background: rgba(17,18,22,0.8);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
position: sticky; top: 0; z-index: 10;
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.admin-topbar .at-title {
|
.admin-topbar .at-title {
|
||||||
font-size: 1rem; font-weight: 600;
|
font-size: 1rem; font-weight: 600;
|
||||||
@@ -96,7 +122,10 @@
|
|||||||
font-size: 0.75rem; color: rgba(255,255,255,0.35);
|
font-size: 0.75rem; color: rgba(255,255,255,0.35);
|
||||||
font-weight: 400;
|
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 {
|
.admin-topbar .at-actions .at-back {
|
||||||
color: rgba(255,255,255,0.5); text-decoration: none;
|
color: rgba(255,255,255,0.5); text-decoration: none;
|
||||||
font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem;
|
font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem;
|
||||||
@@ -152,6 +181,7 @@
|
|||||||
.a-stat .as-value {
|
.a-stat .as-value {
|
||||||
font-size: 1.8rem; font-weight: 700;
|
font-size: 1.8rem; font-weight: 700;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.a-stat .as-bar {
|
.a-stat .as-bar {
|
||||||
position: absolute; bottom: 0; left: 0; height: 2px;
|
position: absolute; bottom: 0; left: 0; height: 2px;
|
||||||
@@ -196,6 +226,11 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive tables: card view on mobile ─── */
|
||||||
|
.a-table-card-view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Buttons ─── */
|
/* ─── Buttons ─── */
|
||||||
.a-btn {
|
.a-btn {
|
||||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
@@ -255,6 +290,7 @@
|
|||||||
color: rgba(255,255,255,0.5);
|
color: rgba(255,255,255,0.5);
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
select.a-input { cursor: pointer; }
|
||||||
|
|
||||||
/* ─── Badge ─── */
|
/* ─── Badge ─── */
|
||||||
.a-badge {
|
.a-badge {
|
||||||
@@ -272,6 +308,7 @@
|
|||||||
.a-overlay {
|
.a-overlay {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
|
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;
|
z-index: 100; display: none; align-items: center; justify-content: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
@@ -283,13 +320,16 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 460px; width: 100%;
|
max-width: 460px; width: 100%;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
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 {
|
.a-modal-title {
|
||||||
font-size: 1rem; font-weight: 600; margin-bottom: 1rem;
|
font-size: 1rem; font-weight: 600; margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.a-modal-actions {
|
.a-modal-actions {
|
||||||
display: flex; gap: 0.5rem; margin-top: 1rem;
|
display: flex; gap: 0.5rem; margin-top: 1rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Grid helpers ─── */
|
/* ─── Grid helpers ─── */
|
||||||
@@ -298,7 +338,14 @@
|
|||||||
.a-gap-sm { gap: 0.5rem; }
|
.a-gap-sm { gap: 0.5rem; }
|
||||||
.a-mt { margin-top: 1rem; }
|
.a-mt { margin-top: 1rem; }
|
||||||
.a-mb { margin-bottom: 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 ─── */
|
/* ─── Empty state ─── */
|
||||||
.a-empty {
|
.a-empty {
|
||||||
@@ -307,20 +354,86 @@
|
|||||||
}
|
}
|
||||||
.a-empty .a-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
.a-empty .a-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
/* ─── Responsive ─── */
|
||||||
|
@media(max-width: 1024px){
|
||||||
|
.admin-sidebar { width: 200px; }
|
||||||
|
}
|
||||||
|
|
||||||
@media(max-width: 768px){
|
@media(max-width: 768px){
|
||||||
.admin-sidebar { width: 56px; }
|
.admin-sidebar {
|
||||||
.admin-sidebar .sb-brand span,
|
position: fixed; left: 0; top: 0;
|
||||||
.admin-sidebar .sb-nav a span:not(.sb-icon),
|
transform: translateX(-100%);
|
||||||
.admin-sidebar .sb-footer span { display: none; }
|
width: 260px;
|
||||||
.admin-sidebar .sb-nav a { justify-content: center; padding: 0.65rem; }
|
box-shadow: 4px 0 30px rgba(0,0,0,0.4);
|
||||||
.admin-sidebar .sb-footer a { justify-content: center; }
|
}
|
||||||
|
.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 %}
|
{% block extra_styles %}{% endblock %}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="admin-sidebar">
|
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||||
|
|
||||||
|
<div class="admin-sidebar" id="adminSidebar">
|
||||||
<div class="sb-brand">
|
<div class="sb-brand">
|
||||||
<span style="font-size:1.2rem">⚡</span>
|
<span style="font-size:1.2rem">⚡</span>
|
||||||
<span>CS2 <span>Admin</span></span>
|
<span>CS2 <span>Admin</span></span>
|
||||||
@@ -344,6 +457,7 @@
|
|||||||
<div class="admin-main">
|
<div class="admin-main">
|
||||||
<div class="admin-topbar">
|
<div class="admin-topbar">
|
||||||
<div class="at-title">
|
<div class="at-title">
|
||||||
|
<button class="hamburger" id="hamburgerBtn" aria-label="Toggle menu">☰</button>
|
||||||
{% block page_title %}Панель управления{% endblock %}
|
{% block page_title %}Панель управления{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<div class="at-actions">
|
<div class="at-actions">
|
||||||
@@ -356,7 +470,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block modals %}{% endblock %}
|
{% block modals %}{% endblock %}
|
||||||
<script src="/static/js/notifications.js"></script>
|
<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 %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+241
-74
@@ -53,6 +53,32 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -60,9 +86,10 @@
|
|||||||
{% block modals %}
|
{% block modals %}
|
||||||
<!-- Create/Edit Case Modal -->
|
<!-- Create/Edit Case Modal -->
|
||||||
<div class="a-overlay" id="caseModal">
|
<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>
|
<div class="a-modal-title" id="caseModalTitle">🎲 Создать кейс</div>
|
||||||
<input type="hidden" id="editCaseName">
|
<input type="hidden" id="editCaseName">
|
||||||
|
<div style="padding:0 1.5rem;flex:1;overflow-y:auto">
|
||||||
<div class="a-row a-gap-sm">
|
<div class="a-row a-gap-sm">
|
||||||
<div class="a-mb" style="flex:1">
|
<div class="a-mb" style="flex:1">
|
||||||
<label class="a-label">Название кейса (ID)</label>
|
<label class="a-label">Название кейса (ID)</label>
|
||||||
@@ -87,30 +114,62 @@
|
|||||||
<label class="a-label">Описание (необязательно)</label>
|
<label class="a-label">Описание (необязательно)</label>
|
||||||
<input type="text" id="caseDescription" class="a-input" placeholder="Описание кейса...">
|
<input type="text" id="caseDescription" class="a-input" placeholder="Описание кейса...">
|
||||||
</div>
|
</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>
|
<!-- Item browser section -->
|
||||||
<div id="caseItemsList"></div>
|
<div class="a-card" style="background:rgba(0,0,0,0.2);padding:0.75rem;margin-bottom:1rem">
|
||||||
<div class="a-flex a-mt" style="flex-wrap:wrap">
|
<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>
|
||||||
<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>
|
<!-- 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>
|
</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" onclick="closeModal('caseModal')">Отмена</button>
|
||||||
<button class="a-btn a-btn-primary" id="saveCaseBtn" onclick="saveCase()">💾 Сохранить</button>
|
<button class="a-btn a-btn-primary" id="saveCaseBtn" onclick="saveCase()">💾 Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 -->
|
<!-- Add case to section modal -->
|
||||||
<div class="a-overlay" id="addCaseModal">
|
<div class="a-overlay" id="addCaseModal">
|
||||||
<div class="a-modal" style="max-width:400px">
|
<div class="a-modal" style="max-width:400px">
|
||||||
@@ -145,13 +204,26 @@
|
|||||||
|
|
||||||
{% block extra_styles %}
|
{% block extra_styles %}
|
||||||
<style>
|
<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 {
|
||||||
.ci-item img { width:32px; height:24px; object-fit:contain; }
|
display:flex; align-items:center; gap:0.5rem;
|
||||||
.ci-item .ci-info { flex:1; }
|
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); }
|
.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; }
|
.ci-result {
|
||||||
.all-item-card:hover { border-color:rgba(245,158,11,0.3); background:rgba(245,158,11,0.05); }
|
display:flex; align-items:center; gap:0.5rem;
|
||||||
.all-item-card img { width:100%; height:40px; object-fit:contain; }
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -159,17 +231,42 @@
|
|||||||
<script>
|
<script>
|
||||||
let currentCaseItems = [];
|
let currentCaseItems = [];
|
||||||
let editingCaseName = null;
|
let editingCaseName = null;
|
||||||
|
let searchDebounce = null;
|
||||||
|
|
||||||
// ─── Modals ──────────────────────────────────────────────────────────────────
|
// ─── Modals ──────────────────────────────────────────────────────────────────
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||||
|
|
||||||
// ─── Load page data ──────────────────────────────────────────────────────────
|
// ─── Load filter options ─────────────────────────────────────────────────────
|
||||||
let allCasesList = [];
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const r = await fetch('/admin/api/cases/list');
|
const r = await fetch('/admin/api/cases/list');
|
||||||
const d = await r.json();
|
allCasesList = await r.json();
|
||||||
allCasesList = d.cases || [];
|
|
||||||
|
// 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 ──────────────────────────────────────────────────────
|
// ─── Create / Edit Case ──────────────────────────────────────────────────────
|
||||||
@@ -185,6 +282,8 @@ function openCreateCaseModal() {
|
|||||||
document.getElementById('caseDescription').value = '';
|
document.getElementById('caseDescription').value = '';
|
||||||
currentCaseItems = [];
|
currentCaseItems = [];
|
||||||
renderCaseItems();
|
renderCaseItems();
|
||||||
|
document.getElementById('caseItemSearch').value = '';
|
||||||
|
document.getElementById('caseItemResults').innerHTML = '';
|
||||||
openModal('caseModal');
|
openModal('caseModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,21 +299,34 @@ function openEditCaseModal(caseName) {
|
|||||||
document.getElementById('casePrice').value = c.price_open || 250;
|
document.getElementById('casePrice').value = c.price_open || 250;
|
||||||
document.getElementById('caseImage').value = c.image_url || '';
|
document.getElementById('caseImage').value = c.image_url || '';
|
||||||
document.getElementById('caseDescription').value = c.description || '';
|
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();
|
renderCaseItems();
|
||||||
|
document.getElementById('caseItemSearch').value = '';
|
||||||
|
document.getElementById('caseItemResults').innerHTML = '';
|
||||||
openModal('caseModal');
|
openModal('caseModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCaseItems() {
|
function renderCaseItems() {
|
||||||
const div = document.getElementById('caseItemsList');
|
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.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'">
|
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||||
<div class="ci-info">
|
<div class="ci-info">
|
||||||
<div>${item.market_hash_name || item.name || '???'}</div>
|
<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>
|
</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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -225,31 +337,105 @@ function removeCaseItem(idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addCaseItem(item) {
|
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);
|
currentCaseItems.push(item);
|
||||||
renderCaseItems();
|
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() {
|
async function searchCaseItems() {
|
||||||
const q = document.getElementById('caseItemSearch').value;
|
const q = document.getElementById('caseItemSearch').value;
|
||||||
if (q.length < 2) return;
|
const rarity = document.getElementById('rarityFilter').value;
|
||||||
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}`);
|
const collection = document.getElementById('collectionFilter').value;
|
||||||
const items = await r.json();
|
const typeF = document.getElementById('typeFilter').value;
|
||||||
// show as dropdown
|
const weaponF = document.getElementById('weaponFilter').value;
|
||||||
let html = '<div style="margin-top:0.5rem;max-height:200px;overflow-y:auto">';
|
const minP = document.getElementById('minPrice').value;
|
||||||
items.forEach(item => {
|
const maxP = document.getElementById('maxPrice').value;
|
||||||
html += `<div class="ci-item" style="cursor:pointer" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'"')})">
|
const sortV = document.getElementById('sortBy').value;
|
||||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
clearTimeout(searchDebounce);
|
||||||
<div class="ci-info"><div>${item.name}</div><div class="ci-rarity">${item.rarity} • ${item.price_rub||0} ₽</div></div>
|
searchDebounce = setTimeout(async () => {
|
||||||
</div>`;
|
const params = new URLSearchParams();
|
||||||
});
|
if (q.length >= 2) params.set('q', q);
|
||||||
html += '</div>';
|
if (rarity) params.set('rarity_filter', rarity);
|
||||||
const existing = document.getElementById('caseItemSearchResults');
|
if (collection) params.set('collection_filter', collection);
|
||||||
if (!existing) {
|
if (typeF) params.set('type_filter', typeF);
|
||||||
const d = document.createElement('div'); d.id = 'caseItemSearchResults';
|
if (weaponF) params.set('weapon_filter', weaponF);
|
||||||
document.getElementById('caseItemSearch').parentNode.appendChild(d);
|
if (minP > 0) params.set('min_price', minP);
|
||||||
|
if (maxP > 0) params.set('max_price', maxP);
|
||||||
|
if (sortV) {
|
||||||
|
const [field, order] = sortV.split('&');
|
||||||
|
params.set('sort_by', field);
|
||||||
|
if (order) params.set('sort_order', 'desc');
|
||||||
}
|
}
|
||||||
document.getElementById('caseItemSearchResults').innerHTML = html;
|
params.set('limit', '100');
|
||||||
|
const r = await fetch(`/admin/api/items/search?${params.toString()}`);
|
||||||
|
const items = await r.json();
|
||||||
|
const div = document.getElementById('caseItemResults');
|
||||||
|
if (items.length === 0) {
|
||||||
|
div.innerHTML = '<div style="font-size:0.75rem;color:rgba(255,255,255,0.3);padding:0.5rem">Ничего не найдено</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
if (collection) {
|
||||||
|
html += `<div style="padding:0.3rem 0.5rem;font-size:0.7rem;color:rgba(255,255,255,0.4);display:flex;justify-content:space-between">
|
||||||
|
<span>Найдено: ${items.length} предметов</span>
|
||||||
|
<span onclick="addAllFromCollection()" style="cursor:pointer;color:#f59e0b">➕ Добавить все из коллекции</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const alreadyIds = new Set(currentCaseItems.map(i => i.id));
|
||||||
|
html += items.filter(item => !alreadyIds.has(item.id)).map(item => {
|
||||||
|
const priceStr = item.price_rub > 0 ? item.price_rub.toLocaleString('ru') + '₽' : '';
|
||||||
|
return `
|
||||||
|
<div class="ci-result" onclick="addCaseItem(${JSON.stringify(item).replace(/"/g,'"')})">
|
||||||
|
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||||||
|
<div class="ci-info">
|
||||||
|
<div>${item.name}</div>
|
||||||
|
<div class="ci-meta">${item.rarity}${item.weapon ? ' • ' + item.weapon : ''}${priceStr ? ' • ' + priceStr : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ci-add">+</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
const hidden = items.filter(item => alreadyIds.has(item.id)).length;
|
||||||
|
if (hidden > 0) {
|
||||||
|
html += `<div style="font-size:0.65rem;color:rgba(255,255,255,0.25);padding:0.3rem 0.5rem">${hidden} предмет(ов) уже в кейсе</div>`;
|
||||||
|
}
|
||||||
|
div.innerHTML = html;
|
||||||
|
}, q.length >= 2 ? 200 : 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCase() {
|
async function saveCase() {
|
||||||
@@ -263,11 +449,15 @@ async function saveCase() {
|
|||||||
fd.append('description', document.getElementById('caseDescription').value);
|
fd.append('description', document.getElementById('caseDescription').value);
|
||||||
fd.append('items_json', JSON.stringify(currentCaseItems));
|
fd.append('items_json', JSON.stringify(currentCaseItems));
|
||||||
|
|
||||||
const isEdit = editingCaseName !== null && editingCaseName !== name;
|
const isEdit = editingCaseName !== null;
|
||||||
const url = isEdit
|
const url = isEdit
|
||||||
? `/admin/api/cases/update/${encodeURIComponent(editingCaseName)}`
|
? `/admin/api/cases/update/${encodeURIComponent(editingCaseName)}`
|
||||||
: '/admin/api/cases/create';
|
: '/admin/api/cases/create';
|
||||||
|
|
||||||
|
if (isEdit && editingCaseName !== name) {
|
||||||
|
fd.append('new_name', name);
|
||||||
|
}
|
||||||
|
|
||||||
const r = await fetch(url, { method:'POST', body:fd });
|
const r = await fetch(url, { method:'POST', body:fd });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.success) { window.location.reload(); }
|
if (d.success) { window.location.reload(); }
|
||||||
@@ -285,29 +475,6 @@ async function deleteCase(name) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── All Items Browser ───────────────────────────────────────────────────────
|
|
||||||
async function openAllItemsModal() {
|
|
||||||
openModal('allItemsModal');
|
|
||||||
document.getElementById('allItemsSearch').value = '';
|
|
||||||
await searchAllItems();
|
|
||||||
}
|
|
||||||
async function searchAllItems() {
|
|
||||||
const q = document.getElementById('allItemsSearch').value;
|
|
||||||
const r = await fetch(`/admin/api/items/search?q=${encodeURIComponent(q)}&limit=200`);
|
|
||||||
const items = await r.json();
|
|
||||||
document.getElementById('allItemsGrid').innerHTML = items.map(item => `
|
|
||||||
<div class="all-item-card" onclick="addCaseItemFromAll(${JSON.stringify(item).replace(/"/g,'"')})">
|
|
||||||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
|
||||||
<div style="margin-top:0.2rem;line-height:1.15">${item.name}</div>
|
|
||||||
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35)">${item.rarity}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
function addCaseItemFromAll(item) {
|
|
||||||
addCaseItem(item);
|
|
||||||
closeModal('allItemsModal');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mainpage Sections ───────────────────────────────────────────────────────
|
// ─── Mainpage Sections ───────────────────────────────────────────────────────
|
||||||
function openAddSectionModal() {
|
function openAddSectionModal() {
|
||||||
document.getElementById('newSectionName').value = '';
|
document.getElementById('newSectionName').value = '';
|
||||||
@@ -336,7 +503,7 @@ async function deleteSection(tab) {
|
|||||||
function openAddCaseModal(tab) {
|
function openAddCaseModal(tab) {
|
||||||
document.getElementById('addCaseSectionName').textContent = tab;
|
document.getElementById('addCaseSectionName').textContent = tab;
|
||||||
const sel = document.getElementById('addCaseSelect');
|
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;
|
sel.dataset.tab = tab;
|
||||||
openModal('addCaseModal');
|
openModal('addCaseModal');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
{% block page_title %}📊 Дашборд{% endblock %}
|
{% block page_title %}📊 Дашборд{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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-stats">
|
||||||
<div class="a-stat">
|
<div class="a-stat">
|
||||||
<div class="as-label">Пользователей</div>
|
<div class="as-label">Пользователей</div>
|
||||||
@@ -55,3 +62,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function loadPromoPhase() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/admin/api/promo-phase');
|
||||||
|
const d = await r.json();
|
||||||
|
const el = document.getElementById('promoPhaseInfo');
|
||||||
|
if (d.current_phase === 'LUCKY') {
|
||||||
|
el.innerHTML = '<span style="color:#22c55e">🍀 LUCKY</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||||
|
} else if (d.current_phase === 'UNLUCKY') {
|
||||||
|
el.innerHTML = '<span style="color:#ef4444">💀 UNLUCKY</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<span style="color:#a3a3a3">⚪ NEUTRAL</span> (осталось ' + Math.floor(d.remaining_seconds / 60) + ' мин)';
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', loadPromoPhase);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block top_actions %}
|
{% block top_actions %}
|
||||||
<a href="/admin/users" class="at-back">← Назад</a>
|
<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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="a-stats" style="grid-template-columns:repeat(4,1fr)">
|
<div class="a-stats">
|
||||||
<div class="a-stat">
|
<div class="a-stat">
|
||||||
<div class="as-label">Баланс</div>
|
<div class="as-label">Баланс</div>
|
||||||
<div class="as-value">{{ target_user.balance|money }} ₽</div>
|
<div class="as-value">{{ target_user.balance|money }} ₽</div>
|
||||||
@@ -49,6 +50,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="a-card-header">🔧 Действия</div>
|
<div class="a-card-header">🔧 Действия</div>
|
||||||
@@ -84,6 +105,27 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -251,6 +293,24 @@ async function loadRPU() {
|
|||||||
document.getElementById('rpuSpent').textContent = Math.round(d.stats.total_spent).toLocaleString() + ' ₽';
|
document.getElementById('rpuSpent').textContent = Math.round(d.stats.total_spent).toLocaleString() + ' ₽';
|
||||||
document.getElementById('rpuOpened').textContent = d.stats.total_opened.toLocaleString();
|
document.getElementById('rpuOpened').textContent = d.stats.total_opened.toLocaleString();
|
||||||
document.getElementById('rpuAutoStatus').textContent = d.auto_adjust ? '✅' : '❌';
|
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(_) {}
|
} catch(_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% block title %}История {{ target_user.username }} — Админ-панель{% endblock %}
|
||||||
|
{% block nav_users %}active{% endblock %}
|
||||||
|
{% block page_title %}
|
||||||
|
📜 История {{ target_user.username }}
|
||||||
|
<span class="at-sub">ID {{ target_user.id }}</span>
|
||||||
|
{% endblock %}
|
||||||
|
{% block top_actions %}
|
||||||
|
<a href="/admin/user/{{ target_user.id }}" class="at-back">← Профиль</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_styles %}
|
||||||
|
<style>
|
||||||
|
.rarity-Covert, .rarity-Rare\ Special\ Item, .rarity-Extraordinary { color: #eb4b4b; }
|
||||||
|
.rarity-Classified { color: #d32ce6; }
|
||||||
|
.rarity-Restricted { color: #8847ff; }
|
||||||
|
.rarity-Mil-Spec, .rarity-Mil-Spec\ Grade { color: #4b69ff; }
|
||||||
|
.rarity-Industrial\ Grade { color: #5e98d9; }
|
||||||
|
.rarity-Consumer\ Grade { color: #b0b0b0; }
|
||||||
|
.mono { font-family: monospace; font-size: 0.72rem; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="a-stats" style="margin-bottom:1rem">
|
||||||
|
<div class="a-stat"><div class="as-label">Потолок</div><div class="as-value">{{ "%.0f"|format(ceiling) }} ₽</div></div>
|
||||||
|
<div class="a-stat"><div class="as-label">Текущая ценность</div><div class="as-value">{{ "%.0f"|format(total_value) }} ₽</div></div>
|
||||||
|
<div class="a-stat"><div class="as-label">Luck budget</div><div class="as-value">{{ "%.1f"|format(rpu.luck_budget) }}%</div></div>
|
||||||
|
<div class="a-stat"><div class="as-label">Hot score</div><div class="as-value">{{ "%.0f"|format(rpu.hot_score or 0) }}</div></div>
|
||||||
|
<div class="a-stat"><div class="as-label">Ceiling mult</div><div class="as-value">{{ "%.2f"|format(rpu.ceiling_multiplier) }}</div></div>
|
||||||
|
<div class="a-stat"><div class="as-label">Комбек</div><div class="as-value">{{ rpu.comeback_active }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-flex a-mb" style="gap:0.5rem">
|
||||||
|
<button class="a-btn a-btn-primary tab-btn active" onclick="switchTab('openings')">📦 Открытия ({{ openings|length }})</button>
|
||||||
|
<button class="a-btn tab-btn" onclick="switchTab('upgrades')">⬆️ Апгрейды ({{ upgrades|length }})</button>
|
||||||
|
<button class="a-btn tab-btn" onclick="switchTab('transactions')">💳 Транзакции ({{ transactions|length }})</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-openings" class="tab-content" style="display:block">
|
||||||
|
<div class="a-table-wrap">
|
||||||
|
<table class="a-table">
|
||||||
|
<thead><tr><th>ID</th><th>Дата</th><th>Кейс</th><th>Предмет</th><th>Редкость</th><th>Float</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for o in openings %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ o.id }}</td>
|
||||||
|
<td>{{ o.opened_at.strftime('%d.%m %H:%M') }}</td>
|
||||||
|
<td>{{ o.case_name }}</td>
|
||||||
|
<td>{{ o.item_name }}</td>
|
||||||
|
<td><span class="rarity-{{ o.rarity|replace(' ', '\\ ') }}">{{ o.rarity }}</span></td>
|
||||||
|
<td>{{ "%.4f"|format(o.float_value) if o.float_value else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="a-empty" style="padding:2rem">Нет открытий</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-upgrades" class="tab-content" style="display:none">
|
||||||
|
<div class="a-table-wrap">
|
||||||
|
<table class="a-table">
|
||||||
|
<thead><tr><th>ID</th><th>Дата</th><th>Вход</th><th>Цель</th><th>Результат</th><th>Базовый шанс</th><th>RPU шанс</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in upgrades %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ u.id }}</td>
|
||||||
|
<td>{{ u.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||||
|
<td>{{ u.input_item_name }}</td>
|
||||||
|
<td>{{ u.target_item_name }}</td>
|
||||||
|
<td style="color:{% if u.success %}#22c55e{% else %}#ef4444{% endif %}">{% if u.success %}✅ Успех{% else %}❌ Провал{% endif %}</td>
|
||||||
|
<td>{{ "%.1f"|format(u.probability * 100 if u.probability else 0) }}%</td>
|
||||||
|
<td>{{ "%.1f"|format(u.rpu_adjusted_probability * 100 if u.rpu_adjusted_probability else 0) }}%</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="a-empty" style="padding:2rem">Нет апгрейдов</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-transactions" class="tab-content" style="display:none">
|
||||||
|
<div class="a-table-wrap">
|
||||||
|
<table class="a-table">
|
||||||
|
<thead><tr><th>ID</th><th>Дата</th><th>Тип</th><th>Сумма</th><th>Комиссия</th><th>Детали</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in transactions %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ t.id }}</td>
|
||||||
|
<td>{{ t.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if t.tx_type == 'card_to_site' %}<span class="a-badge a-badge-success">Депозит</span>
|
||||||
|
{% elif t.tx_type == 'site_to_card' %}<span class="a-badge a-badge-banned">Вывод</span>
|
||||||
|
{% elif t.tx_type == 'promo_bonus' %}<span class="a-badge" style="background:rgba(234,179,8,0.12);color:#eab308">Промо</span>
|
||||||
|
{% else %}<span class="a-badge a-badge-user">{{ t.tx_type }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ "%.0f"|format(t.amount) }} ₽</td>
|
||||||
|
<td>{% if t.fee %}{{ "%.0f"|format(t.fee) }} ₽{% else %}—{% endif %}</td>
|
||||||
|
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ t.details or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="a-empty" style="padding:2rem">Нет транзакций</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function switchTab(name) {
|
||||||
|
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||||||
|
document.getElementById('tab-' + name).style.display = 'block';
|
||||||
|
document.querySelector('.tab-btn[onclick="switchTab(\'' + name + '\')"]').classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
{% block nav_users %}active{% endblock %}
|
{% block nav_users %}active{% endblock %}
|
||||||
{% block page_title %}👥 Пользователи{% endblock %}
|
{% block page_title %}👥 Пользователи{% endblock %}
|
||||||
{% block top_actions %}
|
{% block top_actions %}
|
||||||
<div class="a-flex">
|
<div class="search-form a-flex">
|
||||||
<input type="text" id="userSearch" class="a-input a-input-sm" style="width:200px" placeholder="Поиск по имени..." value="{{ search or '' }}">
|
<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>
|
<button class="a-btn a-btn-primary" onclick="searchUsers()">🔍 Найти</button>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -50,6 +50,40 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="a-empty">
|
<div class="a-empty">
|
||||||
|
|||||||
+34
-15
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<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">
|
<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>
|
<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>
|
<style>
|
||||||
/* Стили для карточек (только для фарм-кейсов) */
|
/* Стили для карточек (только для фарм-кейсов) */
|
||||||
.cards-grid {
|
.cards-grid {
|
||||||
@@ -519,9 +519,10 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
userBalance = data.balance;
|
userBalance = data.balance;
|
||||||
document.querySelectorAll('.user-balance').forEach(el => {
|
const el = document.getElementById('siteBalanceDisplay');
|
||||||
el.textContent = `${Math.floor(data.balance)} ₽`;
|
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();
|
updatePriceDisplay();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1091,7 +1092,7 @@
|
|||||||
const inventoryIds = [];
|
const inventoryIds = [];
|
||||||
for (const trashItem of trashItems) {
|
for (const trashItem of trashItems) {
|
||||||
const invItem = items.find(i => i.item_id === trashItem.id &&
|
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) {
|
if (invItem) {
|
||||||
inventoryIds.push(invItem.id);
|
inventoryIds.push(invItem.id);
|
||||||
}
|
}
|
||||||
@@ -1101,10 +1102,28 @@
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('inventory_ids', JSON.stringify(inventoryIds));
|
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',
|
method: 'POST',
|
||||||
body: formData
|
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) {
|
} catch (e) {
|
||||||
console.error('Ошибка продажи шлака:', e);
|
console.error('Ошибка продажи шлака:', e);
|
||||||
@@ -1702,7 +1721,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="result-footer-enhanced">
|
<div class="result-footer-enhanced">
|
||||||
<span class="result-price-enhanced">${(item.price || 100).toLocaleString()} ₽</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
${item.count > 1 ? `<span class="result-count-badge">x${item.count}</span>` : ''}
|
${item.count > 1 ? `<span class="result-count-badge">x${item.count}</span>` : ''}
|
||||||
@@ -1724,13 +1743,14 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sellResultItem(itemId) {
|
async function sellResultItem(itemId, itemFloat) {
|
||||||
Notify.confirm('Продать этот предмет?', 'Продажа', async function(confirmed) {
|
Notify.confirm('Продать этот предмет?', 'Продажа', async function(confirmed) {
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/web/api/inventory/items');
|
const response = await fetch('/web/api/inventory/items');
|
||||||
const items = await response.json();
|
const items = await response.json();
|
||||||
const inventoryItem = items.find(i => i.item_id === itemId);
|
const inventoryItem = items.find(i => i.item_id === itemId &&
|
||||||
|
Math.abs(i.float - (itemFloat || 0)) < 0.001);
|
||||||
|
|
||||||
if (!inventoryItem) {
|
if (!inventoryItem) {
|
||||||
Notify.error('Предмет не найден в инвентаре');
|
Notify.error('Предмет не найден в инвентаре');
|
||||||
@@ -1855,18 +1875,17 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/sounds.js"></script>
|
<script src="/static/js/sounds.min.js"></script>
|
||||||
<script src="/static/js/websocket.js"></script>
|
<script src="/static/js/websocket.min.js"></script>
|
||||||
<script src="/static/js/notifications.js"></script>
|
<script src="/static/js/notifications.min.js"></script>
|
||||||
<script src="/static/js/safemode.js"></script>
|
<script src="/static/js/safemode.js"></script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function handleAchievements(data) {
|
function handleAchievements(data) {
|
||||||
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
||||||
data.achievements_unlocked.forEach(ach => {
|
data.achievements_unlocked.forEach(ach => {
|
||||||
const title = ach.title || ach.name || 'Достижение';
|
const title = ach.title || ach.name || 'Достижение';
|
||||||
const reward = ach.reward || '';
|
showAchievementPopup(title);
|
||||||
showAchievementPopup(title, reward);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-4
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Кейсы - CS2 Simulator</title>
|
<title>Кейсы - CS2 Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
<style>
|
<style>
|
||||||
.cases-sections {
|
.cases-sections {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -152,6 +152,85 @@
|
|||||||
.btn-open:hover {
|
.btn-open:hover {
|
||||||
background: var(--primary-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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -162,6 +241,33 @@
|
|||||||
<h1>📦 Кейсы</h1>
|
<h1>📦 Кейсы</h1>
|
||||||
<p class="page-subtitle">Выберите кейс для открытия</p>
|
<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">
|
<div class="cases-sections">
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
@@ -207,15 +313,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
<script>
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/sounds.js"></script>
|
<script src="/static/js/sounds.min.js"></script>
|
||||||
<script src="/static/js/websocket.js"></script>
|
<script src="/static/js/websocket.min.js"></script>
|
||||||
<script src="/static/js/notifications.js"></script>
|
<script src="/static/js/notifications.min.js"></script>
|
||||||
<script src="/static/js/safemode.js"></script>
|
<script src="/static/js/safemode.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function toggleSound() {
|
function toggleSound() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Контракты — CS2 Simulator</title>
|
<title>Контракты — CS2 Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include "_nav.html" %}
|
{% include "_nav.html" %}
|
||||||
@@ -685,7 +685,7 @@
|
|||||||
|
|
||||||
function handleAchievements(d) {
|
function handleAchievements(d) {
|
||||||
if(d && d.achievements_unlocked && Array.isArray(d.achievements_unlocked) && d.achievements_unlocked.length)
|
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() {
|
function toggleSound() {
|
||||||
const e = SoundManager.toggle();
|
const e = SoundManager.toggle();
|
||||||
@@ -704,10 +704,10 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.min.js"></script>
|
||||||
<script src="/static/js/sounds.js"></script>
|
<script src="/static/js/sounds.min.js"></script>
|
||||||
<script src="/static/js/websocket.js"></script>
|
<script src="/static/js/websocket.min.js"></script>
|
||||||
<script src="/static/js/notifications.js"></script>
|
<script src="/static/js/notifications.min.js"></script>
|
||||||
<script src="/static/js/safemode.js"></script>
|
<script src="/static/js/safemode.js"></script>
|
||||||
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+12
-10
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Crash - CS2 Simulator</title>
|
<title>Crash - CS2 Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
<style>
|
<style>
|
||||||
.crash-layout { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
|
.crash-layout { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
|
||||||
.crash-canvas-container {
|
.crash-canvas-container {
|
||||||
@@ -116,9 +116,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/js/sounds.js"></script>
|
<script src="/static/js/sounds.min.js"></script>
|
||||||
<script src="/static/js/websocket.js"></script>
|
<script src="/static/js/websocket.min.js"></script>
|
||||||
<script src="/static/js/notifications.js"></script>
|
<script src="/static/js/notifications.min.js"></script>
|
||||||
<script src="/static/js/safemode.js"></script>
|
<script src="/static/js/safemode.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const canvas = document.getElementById('crashCanvas');
|
const canvas = document.getElementById('crashCanvas');
|
||||||
@@ -445,9 +445,10 @@
|
|||||||
myActiveBet = data.bet;
|
myActiveBet = data.bet;
|
||||||
myCashoutMult = null;
|
myCashoutMult = null;
|
||||||
SoundManager.crashBet();
|
SoundManager.crashBet();
|
||||||
document.querySelectorAll('.user-balance').forEach(el => {
|
const el = document.getElementById('siteBalanceDisplay');
|
||||||
el.textContent = `${Math.floor(data.new_balance)} ₽`;
|
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();
|
updateUI();
|
||||||
} else {
|
} else {
|
||||||
SoundManager.error();
|
SoundManager.error();
|
||||||
@@ -468,9 +469,10 @@
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
myCashoutMult = data.multiplier;
|
myCashoutMult = data.multiplier;
|
||||||
SoundManager.crashCashout();
|
SoundManager.crashCashout();
|
||||||
document.querySelectorAll('.user-balance').forEach(el => {
|
const el = document.getElementById('siteBalanceDisplay');
|
||||||
el.textContent = `${Math.floor(data.new_balance)} ₽`;
|
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();
|
updateUI();
|
||||||
} else {
|
} else {
|
||||||
SoundManager.error();
|
SoundManager.error();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CS2 Trade-Up Simulator</title>
|
<title>CS2 Trade-Up Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include "_nav.html" %}
|
{% include "_nav.html" %}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.min.js"></script>
|
||||||
<script src="/static/js/safemode.js"></script>
|
<script src="/static/js/safemode.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Вход - CS2 Simulator</title>
|
<title>Вход - CS2 Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include "_nav.html" %}
|
{% include "_nav.html" %}
|
||||||
|
|||||||
+166
-14
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% if is_public %}Профиль {{ profile_user.username }}{% else %}Профиль{% endif %} - CS2 Simulator</title>
|
<title>{% if is_public %}Профиль {{ profile_user.username }}{% else %}Профиль{% endif %} - CS2 Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
<style>
|
<style>
|
||||||
.profile-layout {
|
.profile-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -140,6 +140,32 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.withdraw-selected-btn {
|
||||||
|
padding: 0.45rem 1.25rem;
|
||||||
|
background: var(--success-color);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: 'Russo One', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.withdraw-selected-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.withdraw-selected-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sell-all-btn {
|
.sell-all-btn {
|
||||||
padding: 0.45rem 1.25rem;
|
padding: 0.45rem 1.25rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -234,6 +260,25 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-withdraw-single {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--success-color);
|
||||||
|
color: var(--success-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-withdraw-single:hover {
|
||||||
|
background: var(--success-color);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -628,6 +673,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:0.75rem;">
|
<div style="display:flex;gap:0.75rem;">
|
||||||
<button class="sell-selected-btn" id="sellSelectedBtn" onclick="sellSelected()" disabled>💰 Продать</button>
|
<button class="sell-selected-btn" id="sellSelectedBtn" onclick="sellSelected()" disabled>💰 Продать</button>
|
||||||
|
<button class="withdraw-selected-btn" id="withdrawSelectedBtn" onclick="withdrawSelected()" disabled>💳 Вывести</button>
|
||||||
<button class="sell-all-btn" onclick="openBulkSellModal()">📦 Массовая</button>
|
<button class="sell-all-btn" onclick="openBulkSellModal()">📦 Массовая</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -658,12 +704,12 @@
|
|||||||
data-rarity="{{ item.rarity }}"
|
data-rarity="{{ item.rarity }}"
|
||||||
data-type="{{ item.type }}"
|
data-type="{{ item.type }}"
|
||||||
data-name="{{ item.name|lower }}"
|
data-name="{{ item.name|lower }}"
|
||||||
data-price="{{ item.price_rub or 100 }}">
|
data-price="{{ item.price_rub }}">
|
||||||
{% if not is_public %}
|
{% if not is_public %}
|
||||||
<input type="checkbox" class="item-select-checkbox"
|
<input type="checkbox" class="item-select-checkbox"
|
||||||
onchange="onItemSelect(this)"
|
onchange="onItemSelect(this)"
|
||||||
data-id="{{ item.id }}"
|
data-id="{{ item.id }}"
|
||||||
data-price="{{ item.price_rub or 100 }}">
|
data-price="{{ item.price_rub }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="skin-image-container">
|
<div class="skin-image-container">
|
||||||
<img src="{{ item.image_url or '/static/placeholder.png' }}"
|
<img src="{{ item.image_url or '/static/placeholder.png' }}"
|
||||||
@@ -681,11 +727,12 @@
|
|||||||
<span>{{ item.wear }}</span>
|
<span>{{ item.wear }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="skin-details" style="margin-top:3px;color:var(--success-color);">
|
<div class="skin-details" style="margin-top:3px;color:var(--success-color);">
|
||||||
<span>💰 {{ (item.price_rub or 100)|money }} ₽</span>
|
<span>💰 {{ (item.price_rub)|money }} ₽</span>
|
||||||
</div>
|
</div>
|
||||||
{% if not is_public %}
|
{% if not is_public %}
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<button onclick="sellSingleItem({{ item.id }})" class="btn-sell-single">Продать</button>
|
<button onclick="sellSingleItem({{ item.id }})" class="btn-sell-single">Продать</button>
|
||||||
|
<button onclick="withdrawSingleItem({{ item.id }}, {{ (item.price_rub)|int }})" class="btn-withdraw-single">Вывести</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -719,7 +766,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bulk-sell-actions">
|
<div class="bulk-sell-actions">
|
||||||
<button class="btn btn-outline" onclick="closeBulkSellModal()">Отмена</button>
|
<button class="btn btn-outline" onclick="closeBulkSellModal()">Отмена</button>
|
||||||
<button class="btn btn-primary" onclick="executeBulkSell()" style="background:var(--danger-color);">Продать</button>
|
<button class="btn btn-primary" onclick="executeBulkSell()" style="background:var(--danger-color);">💰 Продать</button>
|
||||||
|
<button class="btn btn-primary" onclick="executeBulkWithdraw()" style="background:var(--success-color);color:#000;">💳 Вывести</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -772,9 +820,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/js/sounds.js"></script>
|
<script src="/static/js/sounds.min.js"></script>
|
||||||
<script src="/static/js/websocket.js"></script>
|
<script src="/static/js/websocket.min.js"></script>
|
||||||
<script src="/static/js/notifications.js"></script>
|
<script src="/static/js/notifications.min.js"></script>
|
||||||
<script src="/static/js/safemode.js"></script>
|
<script src="/static/js/safemode.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -826,6 +874,7 @@
|
|||||||
const countBadge = document.getElementById('selectedCountBadge');
|
const countBadge = document.getElementById('selectedCountBadge');
|
||||||
const priceBadge = document.getElementById('totalPriceBadge');
|
const priceBadge = document.getElementById('totalPriceBadge');
|
||||||
const sellBtn = document.getElementById('sellSelectedBtn');
|
const sellBtn = document.getElementById('sellSelectedBtn');
|
||||||
|
const withdrawBtn = document.getElementById('withdrawSelectedBtn');
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
countBadge.style.display = 'inline-block';
|
countBadge.style.display = 'inline-block';
|
||||||
countBadge.textContent = count + ' выбрано';
|
countBadge.textContent = count + ' выбрано';
|
||||||
@@ -834,10 +883,12 @@
|
|||||||
priceBadge.style.display = 'inline-block';
|
priceBadge.style.display = 'inline-block';
|
||||||
priceBadge.textContent = total.toFixed(0) + ' ₽';
|
priceBadge.textContent = total.toFixed(0) + ' ₽';
|
||||||
sellBtn.disabled = false;
|
sellBtn.disabled = false;
|
||||||
|
withdrawBtn.disabled = false;
|
||||||
} else {
|
} else {
|
||||||
countBadge.style.display = 'none';
|
countBadge.style.display = 'none';
|
||||||
priceBadge.style.display = 'none';
|
priceBadge.style.display = 'none';
|
||||||
sellBtn.disabled = true;
|
sellBtn.disabled = true;
|
||||||
|
withdrawBtn.disabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,9 +897,10 @@
|
|||||||
const r = await fetch('/web/api/user/balance');
|
const r = await fetch('/web/api/user/balance');
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
document.querySelectorAll('.user-balance').forEach(el => {
|
const el = document.getElementById('siteBalanceDisplay');
|
||||||
el.textContent = Math.floor(d.balance) + ' ₽';
|
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) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
@@ -906,6 +958,64 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withdrawSingleItem(inventoryId, price) {
|
||||||
|
Notify.confirm('Вывести ' + (price||'').toLocaleString() + ' ₽ на карту? (Комиссия 10%)', 'Вывод', function(confirmed) {
|
||||||
|
if (!confirmed) return;
|
||||||
|
SoundManager.click();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('inventory_item_id', inventoryId);
|
||||||
|
fetch('/web/api/withdraw', { method: 'POST', body: formData })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
SoundManager.balanceUpdate();
|
||||||
|
Notify.success('Выведено ' + data.net.toFixed(0) + ' ₽ на карту');
|
||||||
|
const card = document.querySelector('.inventory-skin-card[data-inventory-id="' + inventoryId + '"]');
|
||||||
|
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; setTimeout(() => card.remove(), 300); }
|
||||||
|
refreshBalance();
|
||||||
|
} else {
|
||||||
|
SoundManager.error();
|
||||||
|
Notify.error(data.error || 'Ошибка при выводе');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { SoundManager.error(); Notify.error('Ошибка соединения'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withdrawSelected() {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
let total = 0;
|
||||||
|
selectedItems.forEach(id => { total += itemPrices.get(id) || 100; });
|
||||||
|
const fee = total * 0.1;
|
||||||
|
Notify.confirm('Вывести ' + selectedItems.size + ' предметов на карту?\nСумма: ' + total.toFixed(0) + ' ₽\nКомиссия: ' + fee.toFixed(0) + ' ₽\nК зачислению: ' + (total - fee).toFixed(0) + ' ₽', 'Массовый вывод', function(confirmed) {
|
||||||
|
if (!confirmed) return;
|
||||||
|
SoundManager.click();
|
||||||
|
const ids = Array.from(selectedItems);
|
||||||
|
let completed = 0, errors = 0;
|
||||||
|
ids.forEach(function(invId) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('inventory_item_id', invId);
|
||||||
|
fetch('/web/api/withdraw', { method: 'POST', body: formData })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
completed++;
|
||||||
|
const card = document.querySelector('.inventory-skin-card[data-inventory-id="' + invId + '"]');
|
||||||
|
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; setTimeout(() => card.remove(), 300); }
|
||||||
|
} else {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
if (completed + errors === ids.length) {
|
||||||
|
Notify.success('Выведено ' + completed + ' предметов' + (errors ? ', ошибок: ' + errors : ''));
|
||||||
|
selectedItems.clear();
|
||||||
|
refreshBalance();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { errors++; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openBulkSellModal() {
|
function openBulkSellModal() {
|
||||||
const modal = document.getElementById('bulkSellModal');
|
const modal = document.getElementById('bulkSellModal');
|
||||||
const optionsDiv = document.getElementById('bulkSellOptions');
|
const optionsDiv = document.getElementById('bulkSellOptions');
|
||||||
@@ -997,6 +1107,45 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function executeBulkWithdraw() {
|
||||||
|
const selected = [];
|
||||||
|
document.querySelectorAll('#bulkSellOptions input[type="checkbox"]:checked').forEach(cb => { if (cb.value && cb.value !== 'all') selected.push(cb.value); });
|
||||||
|
const allChecked = document.querySelector('#bulkSellOptions input[value="all"]')?.checked;
|
||||||
|
const ids = [];
|
||||||
|
document.querySelectorAll('.inventory-skin-card').forEach(card => {
|
||||||
|
if (card.style.display !== 'none') {
|
||||||
|
if (allChecked || selected.includes(card.dataset.rarity)) {
|
||||||
|
ids.push(parseInt(card.dataset.inventoryId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (ids.length === 0) { Notify.error('Выберите хотя бы одну редкость'); return; }
|
||||||
|
let totalPrice = 0;
|
||||||
|
ids.forEach(id => { totalPrice += itemPrices.get(id) || 100; });
|
||||||
|
const fee = totalPrice * 0.1;
|
||||||
|
Notify.confirm('Вывести ' + ids.length + ' предметов на карту?\nСумма: ' + totalPrice.toFixed(0) + ' ₽\nКомиссия: ' + fee.toFixed(0) + ' ₽\nК зачислению: ' + (totalPrice - fee).toFixed(0) + ' ₽', 'Массовый вывод', function(confirmed) {
|
||||||
|
if (!confirmed) return;
|
||||||
|
let completed = 0, errors = 0;
|
||||||
|
ids.forEach(function(invId) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('inventory_item_id', invId);
|
||||||
|
fetch('/web/api/withdraw', { method: 'POST', body: formData })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) { completed++; }
|
||||||
|
else { errors++; }
|
||||||
|
if (completed + errors === ids.length) {
|
||||||
|
Notify.success('Выведено ' + completed + ' предметов' + (errors ? ', ошибок: ' + errors : ''));
|
||||||
|
refreshBalance();
|
||||||
|
closeBulkSellModal();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { errors++; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function filterItems() {
|
function filterItems() {
|
||||||
const rf = document.getElementById('rarityFilter').value.toLowerCase();
|
const rf = document.getElementById('rarityFilter').value.toLowerCase();
|
||||||
const tf = document.getElementById('typeFilter').value.toLowerCase();
|
const tf = document.getElementById('typeFilter').value.toLowerCase();
|
||||||
@@ -1071,7 +1220,7 @@
|
|||||||
+ '<div class="skin-details"><span class="rarity-' + rc + '">' + (item.rarity||'') + '</span><span>' + fs + '</span></div>'
|
+ '<div class="skin-details"><span class="rarity-' + rc + '">' + (item.rarity||'') + '</span><span>' + fs + '</span></div>'
|
||||||
+ '<div class="skin-details" style="margin-top:3px;"><span>' + (item.type||'') + '</span><span>' + (item.wear||'') + '</span></div>'
|
+ '<div class="skin-details" style="margin-top:3px;"><span>' + (item.type||'') + '</span><span>' + (item.wear||'') + '</span></div>'
|
||||||
+ '<div class="skin-details" style="margin-top:3px;color:var(--success-color);"><span>💰 ' + ((item.price_rub||100)).toLocaleString() + ' ₽</span></div>'
|
+ '<div class="skin-details" style="margin-top:3px;color:var(--success-color);"><span>💰 ' + ((item.price_rub||100)).toLocaleString() + ' ₽</span></div>'
|
||||||
+ '<div class="item-actions"><button onclick="sellSingleItem(' + item.id + ')" class="btn-sell-single">Продать</button></div></div></div>';
|
+ '<div class="item-actions"><button onclick="sellSingleItem(' + item.id + ')" class="btn-sell-single">Продать</button><button onclick="withdrawSingleItem(' + item.id + ', ' + (item.price_rub||100) + ')" class="btn-withdraw-single">Вывести</button></div></div></div>';
|
||||||
});
|
});
|
||||||
grid.innerHTML = html;
|
grid.innerHTML = html;
|
||||||
selectedItems.clear();
|
selectedItems.clear();
|
||||||
@@ -1096,7 +1245,7 @@
|
|||||||
grid.addEventListener('click', function(e) {
|
grid.addEventListener('click', function(e) {
|
||||||
const card = e.target.closest('.inventory-skin-card');
|
const card = e.target.closest('.inventory-skin-card');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
if (e.target.closest('.item-select-checkbox, .btn-sell-single')) return;
|
if (e.target.closest('.item-select-checkbox, .btn-sell-single, .btn-withdraw-single')) return;
|
||||||
const invId = card.dataset.inventoryId;
|
const invId = card.dataset.inventoryId;
|
||||||
if (!invId) return;
|
if (!invId) return;
|
||||||
openItemCard(invId);
|
openItemCard(invId);
|
||||||
@@ -1155,7 +1304,10 @@
|
|||||||
+ '<div class="ic-detail"><span class="ic-label">Тип</span><span class="ic-value">' + (item.type || 'Normal') + '</span></div>'
|
+ '<div class="ic-detail"><span class="ic-label">Тип</span><span class="ic-value">' + (item.type || 'Normal') + '</span></div>'
|
||||||
+ '<div class="ic-detail"><span class="ic-label">Цена</span><span class="ic-value" style="color:var(--success-color)">💰 ' + (item.price_rub || 0).toLocaleString() + ' ₽</span></div>'
|
+ '<div class="ic-detail"><span class="ic-label">Цена</span><span class="ic-value" style="color:var(--success-color)">💰 ' + (item.price_rub || 0).toLocaleString() + ' ₽</span></div>'
|
||||||
+ (collHtml ? '<div class="ic-collection-title">🎨 Скины коллекции</div><div class="ic-collection-list">' + collHtml + '</div>' : '')
|
+ (collHtml ? '<div class="ic-collection-title">🎨 Скины коллекции</div><div class="ic-collection-list">' + collHtml + '</div>' : '')
|
||||||
+ '<button class="btn-sell-single ic-sell-btn" onclick="sellSingleItem(' + item.id + '); closeItemCard();">Продать за ' + (item.price_rub || 0).toLocaleString() + ' ₽</button>'
|
+ '<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">'
|
||||||
|
+ '<button class="ic-sell-btn" style="flex:1;" onclick="sellSingleItem(' + item.id + '); closeItemCard();">Продать за ' + (item.price_rub || 0).toLocaleString() + ' ₽</button>'
|
||||||
|
+ '<button class="ic-sell-btn" style="flex:1;background:var(--success-color);color:#000;" onclick="withdrawSingleItem(' + item.id + ', ' + (item.price_rub || 100) + '); closeItemCard();">Вывести</button>'
|
||||||
|
+ '</div>'
|
||||||
+ '</div></div>';
|
+ '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Регистрация - CS2 Simulator</title>
|
<title>Регистрация - CS2 Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include "_nav.html" %}
|
{% include "_nav.html" %}
|
||||||
|
|||||||
+19
-13
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<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">
|
<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>
|
<title>Апгрейд — CS2 Simulator</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="upgrade-page">
|
<body class="upgrade-page">
|
||||||
{% include "_nav.html" %}
|
{% include "_nav.html" %}
|
||||||
@@ -517,9 +517,10 @@
|
|||||||
const r = await fetch('/web/api/user/balance');
|
const r = await fetch('/web/api/user/balance');
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
document.querySelectorAll('.user-balance').forEach(el => {
|
const el = document.getElementById('siteBalanceDisplay');
|
||||||
el.textContent = `${Math.floor(d.balance).toLocaleString()} ₽`;
|
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) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -609,6 +610,8 @@
|
|||||||
let tickLastDeg = 0, tickCumulative = 0, tickNextThreshold = 15;
|
let tickLastDeg = 0, tickCumulative = 0, tickNextThreshold = 15;
|
||||||
let tickLastTickCumulative = 0;
|
let tickLastTickCumulative = 0;
|
||||||
let tickControlPlayed = false;
|
let tickControlPlayed = false;
|
||||||
|
let lastTickSoundTime = 0;
|
||||||
|
const TICK_SOUND_COOLDOWN = 60;
|
||||||
const tickSlowStart = data.final_angle - 90;
|
const tickSlowStart = data.final_angle - 90;
|
||||||
const tickStartTime = performance.now();
|
const tickStartTime = performance.now();
|
||||||
const tickMaxDuration = spinDuration * 1000 + 500;
|
const tickMaxDuration = spinDuration * 1000 + 500;
|
||||||
@@ -634,16 +637,18 @@
|
|||||||
if (delta < -180) delta += 360;
|
if (delta < -180) delta += 360;
|
||||||
tickCumulative += delta;
|
tickCumulative += delta;
|
||||||
tickLastDeg = deg;
|
tickLastDeg = deg;
|
||||||
if (tickCumulative >= tickNextThreshold) {
|
const now = performance.now();
|
||||||
|
if (tickCumulative >= tickNextThreshold && now - lastTickSoundTime >= TICK_SOUND_COOLDOWN) {
|
||||||
SoundManager.wheelSpin();
|
SoundManager.wheelSpin();
|
||||||
|
lastTickSoundTime = now;
|
||||||
tickLastTickCumulative = tickCumulative;
|
tickLastTickCumulative = tickCumulative;
|
||||||
tickNextThreshold += tickCumulative < tickSlowStart ? 15 : 30;
|
tickNextThreshold += tickCumulative < tickSlowStart ? 15 : 30;
|
||||||
}
|
}
|
||||||
// Контрольный тик: если до финала <10°, а последний тик был >15° назад
|
|
||||||
const remaining = data.final_angle - tickCumulative;
|
const remaining = data.final_angle - tickCumulative;
|
||||||
const gap = tickCumulative - tickLastTickCumulative;
|
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();
|
SoundManager.wheelSpin();
|
||||||
|
lastTickSoundTime = now;
|
||||||
tickControlPlayed = true;
|
tickControlPlayed = true;
|
||||||
}
|
}
|
||||||
tickRAF = requestAnimationFrame(tickLoop);
|
tickRAF = requestAnimationFrame(tickLoop);
|
||||||
@@ -661,6 +666,7 @@
|
|||||||
const showResult = () => {
|
const showResult = () => {
|
||||||
if (resultShown) return;
|
if (resultShown) return;
|
||||||
resultShown = true;
|
resultShown = true;
|
||||||
|
SoundManager.stopAll();
|
||||||
if (data.upgrade_success && data.received_item) {
|
if (data.upgrade_success && data.received_item) {
|
||||||
document.getElementById('displayTargetImage').src = data.received_item.image_url || '/static/placeholder.png';
|
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('displayTargetName').textContent = data.received_item.name.substring(0, 25);
|
||||||
@@ -735,7 +741,7 @@
|
|||||||
item_id: item.item_id,
|
item_id: item.item_id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
rarity: item.rarity,
|
rarity: item.rarity,
|
||||||
price_rub: item.price || item.price_rub || 100,
|
price_rub: item.price || item.price_rub || 0,
|
||||||
image_url: img,
|
image_url: img,
|
||||||
wear: item.wear || '',
|
wear: item.wear || '',
|
||||||
float: item.float || item.float_value || 0
|
float: item.float || item.float_value || 0
|
||||||
@@ -753,18 +759,18 @@
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/sounds.js"></script>
|
<script src="/static/js/sounds.min.js"></script>
|
||||||
<script src="/static/js/websocket.js"></script>
|
<script src="/static/js/websocket.min.js"></script>
|
||||||
<script src="/static/js/notifications.js"></script>
|
<script src="/static/js/notifications.min.js"></script>
|
||||||
<script src="/static/js/safemode.js"></script>
|
<script src="/static/js/safemode.js"></script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function handleAchievements(data) {
|
function handleAchievements(data) {
|
||||||
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
||||||
data.achievements_unlocked.forEach(ach => {
|
data.achievements_unlocked.forEach(ach => {
|
||||||
const title = ach.title || ach.name || 'Достижение';
|
const title = ach.title || ach.name || 'Достижение';
|
||||||
const reward = ach.reward || '';
|
const reward = ach.reward || '';
|
||||||
showAchievementPopup(title, reward);
|
showAchievementPopup(title);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-33
@@ -1,91 +1,150 @@
|
|||||||
import random
|
import random
|
||||||
import math
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from database import User, UpgradeRPU, Upgrade
|
from database import User, UpgradeRPU, Upgrade
|
||||||
|
|
||||||
|
MAX_WIN_STREAK = 3
|
||||||
|
MAX_LOSE_STREAK = 5
|
||||||
|
|
||||||
|
|
||||||
def get_upgrade_rpu(db: Session, user_id: int) -> UpgradeRPU:
|
def get_upgrade_rpu(db: Session, user_id: int) -> UpgradeRPU:
|
||||||
"""Получает или создает настройки РПУ апгрейдов"""
|
|
||||||
rpu = db.query(UpgradeRPU).filter(UpgradeRPU.user_id == user_id).first()
|
rpu = db.query(UpgradeRPU).filter(UpgradeRPU.user_id == user_id).first()
|
||||||
if not rpu:
|
if not rpu:
|
||||||
rpu = UpgradeRPU(user_id=user_id)
|
rpu = UpgradeRPU(user_id=user_id)
|
||||||
db.add(rpu)
|
db.add(rpu)
|
||||||
db.commit()
|
from rpu import _rpu_commit_or_flush
|
||||||
|
_rpu_commit_or_flush(db)
|
||||||
db.refresh(rpu)
|
db.refresh(rpu)
|
||||||
return 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:
|
def calculate_upgrade_probability(input_price: float, target_price: float) -> float:
|
||||||
if input_price <= 0 or target_price <= 0:
|
if input_price <= 0 or target_price <= 0:
|
||||||
return 50.0
|
return 50.0
|
||||||
|
|
||||||
if target_price <= input_price:
|
if target_price <= input_price:
|
||||||
return -1.0
|
return -1.0
|
||||||
|
|
||||||
probability = (input_price / target_price) * 100
|
probability = (input_price / target_price) * 100
|
||||||
|
|
||||||
return max(1.0, min(75.0, probability))
|
return max(1.0, min(75.0, probability))
|
||||||
|
|
||||||
|
|
||||||
def get_adjusted_upgrade_probability(db: Session, user_id: int, base_probability: float) -> float:
|
def get_adjusted_upgrade_probability(db: Session, user_id: int, base_probability: float) -> float:
|
||||||
if base_probability < 0:
|
if base_probability < 0:
|
||||||
return -1.0
|
return -1.0
|
||||||
|
|
||||||
rpu = get_upgrade_rpu(db, user_id)
|
rpu = get_upgrade_rpu(db, user_id)
|
||||||
adjusted = base_probability * rpu.upgrade_multiplier
|
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))
|
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]:
|
def spin_upgrade_wheel(success_probability: float) -> tuple[bool, float, int, float]:
|
||||||
raw_angle = random.uniform(0, 360)
|
raw_angle = random.uniform(0, 360)
|
||||||
|
|
||||||
half_green = (success_probability / 100) * 180
|
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
|
success = 180 - half_green <= raw_angle <= 180 + half_green
|
||||||
|
|
||||||
margin = max(3, half_green * 0.12)
|
margin = max(3, half_green * 0.12)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
rotations = random.randint(7, 9)
|
rotations = random.randint(7, 9)
|
||||||
lo = max(0, 180 - half_green + margin)
|
lo = max(0, 180 - half_green + margin)
|
||||||
hi = min(360, 180 + half_green - margin)
|
hi = min(360, 180 + half_green - margin)
|
||||||
if lo < hi:
|
target_angle = random.uniform(lo, hi) if lo < hi else random.uniform(
|
||||||
target_angle = random.uniform(lo, hi)
|
max(0, 180 - half_green + 1), min(360, 180 + half_green - 1))
|
||||||
else:
|
|
||||||
target_angle = random.uniform(max(0, 180 - half_green + 1), min(360, 180 + half_green - 1))
|
|
||||||
else:
|
else:
|
||||||
rotations = random.randint(6, 8)
|
rotations = random.randint(6, 8)
|
||||||
left_size = max(0, 180 - half_green - 3)
|
left_size = max(0, 180 - half_green - 3)
|
||||||
right_start = min(360, 180 + half_green + 3)
|
right_start = min(360, 180 + half_green + 3)
|
||||||
right_size = max(0, 360 - right_start)
|
right_size = max(0, 360 - right_start)
|
||||||
if left_size >= right_size:
|
lo, hi = (0, max(0, 180 - half_green - 3)) if left_size >= right_size else (min(360, 180 + half_green + 3), 360)
|
||||||
lo = 0
|
target_angle = random.uniform(lo, hi) if lo < hi else random.uniform(min(360, 180 + half_green + 3), 360)
|
||||||
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)
|
|
||||||
|
|
||||||
final_angle = target_angle + rotations * 360
|
final_angle = target_angle + rotations * 360
|
||||||
|
|
||||||
return success, final_angle, rotations, raw_angle
|
return success, final_angle, rotations, raw_angle
|
||||||
|
|
||||||
|
|
||||||
def update_upgrade_stats(db: Session, user_id: int, success: bool, input_price: float):
|
def update_upgrade_stats(db: Session, user_id: int, success: bool, input_price: float):
|
||||||
rpu = get_upgrade_rpu(db, user_id)
|
rpu = get_upgrade_rpu(db, user_id)
|
||||||
rpu.total_attempts += 1
|
rpu.total_attempts += 1
|
||||||
if success:
|
if success:
|
||||||
rpu.total_success += 1
|
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:
|
else:
|
||||||
rpu.total_spent_value += input_price
|
rpu.total_spent_value += input_price
|
||||||
|
rpu.current_lose_streak += 1
|
||||||
if rpu.auto_adjust and rpu.total_attempts >= 15:
|
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
|
rate = rpu.total_success / rpu.total_attempts
|
||||||
if rate < 0.2:
|
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:
|
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