971 lines
37 KiB
Python
971 lines
37 KiB
Python
# main.py
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
from pydantic import BaseModel, field_validator, validator
|
|
from typing import List, Optional, Dict, Any
|
|
import json
|
|
from pathlib import Path
|
|
from collections import Counter, defaultdict
|
|
import random
|
|
import sys
|
|
from tqdm import tqdm
|
|
|
|
app = FastAPI(
|
|
title="CS2 Trade-Up Contract Simulator API",
|
|
description="Симуляция контрактов 10→1 по механике CS2",
|
|
version="1.0.0"
|
|
)
|
|
|
|
DATA_FILE = Path("skins.json")
|
|
CUSTOM_DATA_FILE = Path("custom_items.json")
|
|
|
|
# ─── Загрузка кастомных предметов ─────────────────────────────────────────
|
|
def load_custom_items() -> List[dict]:
|
|
"""Загружает кастомные предметы из отдельного файла"""
|
|
custom_items = []
|
|
if CUSTOM_DATA_FILE.exists():
|
|
try:
|
|
with CUSTOM_DATA_FILE.open("r", encoding="utf-8") as f:
|
|
custom_items = json.load(f)
|
|
print(f"Загружено {len(custom_items)} кастомных предметов")
|
|
except Exception as e:
|
|
print(f"Ошибка загрузки кастомных предметов: {e}")
|
|
else:
|
|
# Создаем пример файла с кастомными предметами
|
|
example_custom = [
|
|
{
|
|
"market_hash_name": "★ Dildo | Fade (Factory New)",
|
|
"weapon": "Dildo",
|
|
"pattern": "Fade",
|
|
"rarity": "Covert",
|
|
"collection": "",
|
|
"type": "Normal",
|
|
"wear": "Factory New",
|
|
"wear_range": [0.00, 0.07],
|
|
"mid_float_used": 0.01,
|
|
"doppler_phase": None,
|
|
"price_usd": 1000.0,
|
|
"price_rub": 100000.0,
|
|
"price_source": "custom",
|
|
"image_url": "",
|
|
"crates_names": ["create: dildo_case"],
|
|
"float_range": [0.00, 0.07],
|
|
"can_be_assigned_from": ["create: dildo_case"],
|
|
"is_craftable": True
|
|
},
|
|
{
|
|
"market_hash_name": "★ Dildo | Crimson Web (Minimal Wear)",
|
|
"weapon": "Dildo",
|
|
"pattern": "Crimson Web",
|
|
"rarity": "Covert",
|
|
"collection": "",
|
|
"type": "Normal",
|
|
"wear": "Minimal Wear",
|
|
"wear_range": [0.07, 0.15],
|
|
"mid_float_used": 0.10,
|
|
"doppler_phase": None,
|
|
"price_usd": 800.0,
|
|
"price_rub": 80000.0,
|
|
"price_source": "custom",
|
|
"image_url": "",
|
|
"crates_names": ["create: dildo_case"],
|
|
"float_range": [0.07, 0.15],
|
|
"can_be_assigned_from": ["create: dildo_case"],
|
|
"is_craftable": True
|
|
},
|
|
{
|
|
"market_hash_name": "Dildo | Blue Gem (Factory New)",
|
|
"weapon": "Dildo",
|
|
"pattern": "Blue Gem",
|
|
"rarity": "Classified",
|
|
"collection": "",
|
|
"type": "Normal",
|
|
"wear": "Factory New",
|
|
"wear_range": [0.00, 0.07],
|
|
"mid_float_used": 0.01,
|
|
"doppler_phase": None,
|
|
"price_usd": 500.0,
|
|
"price_rub": 50000.0,
|
|
"price_source": "custom",
|
|
"image_url": "",
|
|
"crates_names": ["create: dildo_case"],
|
|
"float_range": [0.00, 0.07],
|
|
"can_be_assigned_from": ["create: dildo_case"],
|
|
"is_craftable": True
|
|
}
|
|
]
|
|
try:
|
|
with CUSTOM_DATA_FILE.open("w", encoding="utf-8") as f:
|
|
json.dump(example_custom, f, ensure_ascii=False, indent=2)
|
|
print(f"Создан пример файла кастомных предметов: {CUSTOM_DATA_FILE}")
|
|
custom_items = example_custom
|
|
except Exception as e:
|
|
print(f"Не удалось создать файл кастомных предметов: {e}")
|
|
|
|
return custom_items
|
|
|
|
# ─── Генерация sub-вариаций цен и float ───────────────────────────────────
|
|
def generate_item_variants(num_variants: int = 6):
|
|
"""Для каждого предмета генерирует sub-вариации с разными ценами и float"""
|
|
global ALL_ITEMS
|
|
new_all = []
|
|
for item in ALL_ITEMS:
|
|
base_price = item.get("price_rub", 0)
|
|
wr = item.get("wear_range", [0, 1])
|
|
mid = item.get("mid_float_used", 0.5)
|
|
wr_min, wr_max = wr
|
|
f_range = max(wr_max - wr_min, 0.001)
|
|
|
|
for v in range(num_variants):
|
|
variant = dict(item)
|
|
t = v / (num_variants - 1) if num_variants > 1 else 0
|
|
# price factor: 0.55 to 1.40 across variants
|
|
factor = 0.55 + t * 0.85
|
|
# add small random jitter
|
|
factor += random.uniform(-0.05, 0.05)
|
|
factor = max(0.30, min(1.50, factor))
|
|
|
|
p = int(base_price * factor)
|
|
# scramble last digits
|
|
if p >= 1_000_000:
|
|
prefix = p // 1000
|
|
suffix = random.randint(100, 999)
|
|
p = prefix * 1000 + suffix
|
|
elif p >= 100_000:
|
|
prefix = p // 100
|
|
suffix = random.randint(10, 99)
|
|
p = prefix * 100 + suffix
|
|
elif p >= 10_000:
|
|
prefix = p // 10
|
|
suffix = random.randint(1, 9)
|
|
p = prefix * 10 + suffix
|
|
variant["price_rub"] = max(p, 100)
|
|
variant["price_usd"] = round(variant["price_rub"] / 100, 2)
|
|
|
|
# float spreads across the wear range
|
|
float_offset = -0.4 + t * 0.8
|
|
float_offset += random.uniform(-0.1, 0.1)
|
|
new_mid = mid + float_offset * f_range
|
|
new_mid = max(wr_min + 0.001, min(wr_max - 0.001, new_mid))
|
|
variant["mid_float_used"] = round(new_mid, 4)
|
|
|
|
new_all.append(variant)
|
|
|
|
count_before = len(ALL_ITEMS)
|
|
ALL_ITEMS = new_all
|
|
print(f"Сгенерировано {len(ALL_ITEMS)} sub-вариаций из {count_before} предметов (x{num_variants})")
|
|
|
|
# ─── Загрузка основных данных ─────────────────────────────────────────────
|
|
def load_base_items() -> List[dict]:
|
|
"""Загружает основные предметы из skins.json"""
|
|
try:
|
|
with DATA_FILE.open("r", encoding="utf-8") as f:
|
|
items = json.load(f)
|
|
print(f"Загружено {len(items):,} основных записей")
|
|
return items
|
|
except FileNotFoundError:
|
|
print(f"Критическая ошибка: файл {DATA_FILE} не найден")
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Ошибка чтения файла данных: {e}")
|
|
sys.exit(1)
|
|
|
|
# Загружаем все предметы (основные + кастомные)
|
|
BASE_ITEMS = load_base_items()
|
|
CUSTOM_ITEMS = load_custom_items()
|
|
|
|
# Объединяем предметы: сначала основные, потом кастомные
|
|
ALL_ITEMS = BASE_ITEMS + CUSTOM_ITEMS
|
|
print(f"Всего предметов после объединения: {len(ALL_ITEMS):,}")
|
|
|
|
# Генерируем sub-вариации цен и float для каждого предмета
|
|
generate_item_variants(num_variants=6)
|
|
print(f"Всего предметов после генерации вариаций: {len(ALL_ITEMS):,}")
|
|
|
|
ITEM_BY_ID: Dict[int, dict] = {i: item for i, item in enumerate(ALL_ITEMS)}
|
|
|
|
# ─── Глобальные кэши / индексы ──────────────────────────────────────────────
|
|
ALL_ITEMS_WITH_ID: List[dict] = []
|
|
ITEMS_BY_TYPE: Dict[str, List[dict]] = defaultdict(list)
|
|
ITEMS_BY_RARITY: Dict[str, List[dict]] = defaultdict(list)
|
|
NAME_LOWER_TO_IDS: Dict[str, List[int]] = defaultdict(list)
|
|
COLLECTIONS_INDEX: Dict[str, List[int]] = {}
|
|
CAN_BE_ASSIGNED_FROM_INDEX: Dict[str, List[int]] = defaultdict(list)
|
|
|
|
# ─── Проверка типа предмета ───────────────────────────────────────────────
|
|
def is_knife(item: dict) -> bool:
|
|
"""Проверяет, является ли предмет ножом"""
|
|
weapon = item.get("weapon", "")
|
|
return "Knife" in weapon or "Bayonet" in weapon
|
|
|
|
def is_gloves(item: dict) -> bool:
|
|
"""Проверяет, являются ли предмет перчатками"""
|
|
weapon = item.get("weapon", "")
|
|
return "Wraps" in weapon or "Gloves" in weapon or "Hand" in weapon
|
|
|
|
def is_rare_special_item(item: dict) -> bool:
|
|
"""Проверяет, является ли предмет Rare Special Item (ножи и перчатки)"""
|
|
return is_knife(item) or is_gloves(item)
|
|
|
|
# ─── Редкости ──────────────────────────────────────────────────────────────
|
|
RARITY_ORDER = {
|
|
"Consumer Grade": 0,
|
|
"Industrial Grade": 1,
|
|
"Mil-Spec": 2,
|
|
"Mil-Spec Grade": 2,
|
|
"Restricted": 3,
|
|
"Classified": 4,
|
|
"Covert": 5, # Базовый уровень для Covert
|
|
"Rare Special Item": 6, # Уровень для ножей/перчаток (конвертируется из Covert)
|
|
"Extraordinary": 7, # Для некоторых перчаток
|
|
"Contraband": 8,
|
|
"Unknown": -1,
|
|
}
|
|
|
|
def get_rarity_level(rarity: str | None, item: Optional[dict] = None) -> int:
|
|
"""
|
|
Возвращает числовой уровень редкости.
|
|
Для ножей и перчаток с редкостью Covert возвращает уровень Rare Special Item (6)
|
|
"""
|
|
if not rarity:
|
|
return -1
|
|
|
|
rarity = rarity.strip()
|
|
|
|
# Если это Extraordinary - это уже правильный уровень
|
|
if rarity == "Extraordinary":
|
|
return RARITY_ORDER["Extraordinary"]
|
|
|
|
# Если передан предмет и это Covert
|
|
if item and rarity == "Covert":
|
|
# Проверяем, является ли предмет ножом или перчатками
|
|
if is_rare_special_item(item):
|
|
return RARITY_ORDER["Rare Special Item"]
|
|
else:
|
|
return RARITY_ORDER["Covert"]
|
|
|
|
return RARITY_ORDER.get(rarity, -1)
|
|
|
|
# ─── Модели ────────────────────────────────────────────────────────────────
|
|
class ItemShort(BaseModel):
|
|
id: int
|
|
market_hash_name: str
|
|
rarity: Optional[str] = None
|
|
collection: Optional[str] = None
|
|
type: Optional[str] = None
|
|
price_rub: Optional[float] = None
|
|
price_usd: Optional[float] = None
|
|
|
|
class ContractSubmitRequest(BaseModel):
|
|
item_ids: List[int]
|
|
|
|
@field_validator("item_ids")
|
|
@classmethod
|
|
def check_length(cls, v):
|
|
if len(v) != 10:
|
|
raise ValueError("Нужно ровно 10 предметов")
|
|
return v
|
|
|
|
class TradeUpSimulationResult(BaseModel):
|
|
success: bool
|
|
input_items: List[ItemShort]
|
|
received_item: Optional[ItemShort] = None
|
|
received_float: Optional[float] = None
|
|
probability: Optional[float] = None
|
|
message: str
|
|
|
|
class CollectionItemBase(BaseModel):
|
|
base_name: str
|
|
rarity: str
|
|
item_ids_normal: List[int]
|
|
item_ids_stattrak: List[int]
|
|
item_ids_souvenir: List[int]
|
|
count_variants: int
|
|
|
|
class CollectionDetail(BaseModel):
|
|
collection: str
|
|
total_unique_skins: int
|
|
items: List[CollectionItemBase]
|
|
|
|
# ─── Вспомогательные ───────────────────────────────────────────────────────
|
|
def get_item(idx: int) -> Optional[dict]:
|
|
return ITEM_BY_ID.get(idx)
|
|
|
|
def is_final_in_collection(item: dict) -> bool:
|
|
"""Проверяет, есть ли предметы выше редкостью в той же коллекции/источнике"""
|
|
my_level = get_rarity_level(item.get("rarity"), item)
|
|
|
|
# Собираем все источники, откуда мог выпасть предмет
|
|
sources = []
|
|
|
|
# Из collection
|
|
coll = item.get("collection", "").strip()
|
|
if coll and coll != "Без коллекции":
|
|
sources.append(coll)
|
|
|
|
# Из can_be_assigned_from
|
|
assigned = item.get("can_be_assigned_from", [])
|
|
if isinstance(assigned, list):
|
|
sources.extend(assigned)
|
|
elif isinstance(assigned, str) and assigned:
|
|
sources.append(assigned)
|
|
|
|
# Из crates_names (кейсы)
|
|
crates = item.get("crates_names", [])
|
|
if crates and isinstance(crates, list):
|
|
for crate in crates:
|
|
sources.append(f"Case: {crate}")
|
|
|
|
# Проверяем каждый источник
|
|
for source in sources:
|
|
# Проверяем в COLLECTIONS_INDEX
|
|
for idx in COLLECTIONS_INDEX.get(source, []):
|
|
other = get_item(idx)
|
|
if other:
|
|
other_level = get_rarity_level(other.get("rarity"), other)
|
|
if other_level > my_level:
|
|
return False
|
|
|
|
# Проверяем в CAN_BE_ASSIGNED_FROM_INDEX
|
|
for idx in CAN_BE_ASSIGNED_FROM_INDEX.get(source, []):
|
|
other = get_item(idx)
|
|
if other:
|
|
other_level = get_rarity_level(other.get("rarity"), other)
|
|
if other_level > my_level:
|
|
return False
|
|
|
|
return True
|
|
|
|
def can_be_upgraded(items: List[dict]) -> tuple[bool, str]:
|
|
"""Проверяет, можно ли использовать эти предметы в контракте"""
|
|
if len(items) != 10:
|
|
return False, "Нужно ровно 10 предметов"
|
|
|
|
# Проверяем is_craftable
|
|
for item in items:
|
|
if not item.get("is_craftable", True):
|
|
return False, f"Предмет нельзя использовать в крафте: {item.get('market_hash_name', '???')}"
|
|
|
|
# Проверяем, что ни один предмет не является финальным
|
|
for item in items:
|
|
if is_final_in_collection(item):
|
|
return False, f"Нельзя использовать финальный предмет коллекции: {item.get('market_hash_name', '???')}"
|
|
|
|
# Проверяем тип (Normal/StatTrak/Souvenir)
|
|
types = {it.get("type") for it in items if it.get("type")}
|
|
if len(types) != 1:
|
|
return False, "Все предметы должны быть одного типа"
|
|
|
|
# Проверяем редкость с учетом типа предмета
|
|
rarities = set()
|
|
for it in items:
|
|
level = get_rarity_level(it.get("rarity"), it)
|
|
rarities.add(level)
|
|
|
|
if len(rarities) != 1:
|
|
return False, "Все предметы должны быть одной редкости"
|
|
|
|
return True, ""
|
|
|
|
def extract_base_name(market_hash_name: str) -> str:
|
|
"""Извлекает базовое имя скина без StatTrak, Souvenir и качества"""
|
|
name = market_hash_name.strip()
|
|
|
|
# Убираем префиксы
|
|
for prefix in ["StatTrak™ ", "Souvenir "]:
|
|
if name.startswith(prefix):
|
|
name = name[len(prefix):].strip()
|
|
|
|
# Убираем качество в скобках
|
|
if " (" in name:
|
|
name = name.split(" (", 1)[0].strip()
|
|
|
|
return name
|
|
|
|
def build_indexes():
|
|
"""Строит все индексы"""
|
|
global COLLECTIONS_INDEX, CAN_BE_ASSIGNED_FROM_INDEX
|
|
|
|
coll = defaultdict(list)
|
|
assigned_from = defaultdict(list)
|
|
|
|
for idx, item in ITEM_BY_ID.items():
|
|
# Индекс коллекций
|
|
c = item.get("collection", "").strip()
|
|
if c and c != "Без коллекции":
|
|
coll[c].append(idx)
|
|
|
|
# Индекс can_be_assigned_from
|
|
assigned = item.get("can_be_assigned_from", [])
|
|
if isinstance(assigned, list):
|
|
for source in assigned:
|
|
assigned_from[source].append(idx)
|
|
elif isinstance(assigned, str) and assigned:
|
|
assigned_from[assigned].append(idx)
|
|
|
|
# Индекс кейсов (crates_names)
|
|
crates = item.get("crates_names", [])
|
|
if crates and isinstance(crates, list):
|
|
for crate in crates:
|
|
collection_name = f"Case: {crate}"
|
|
coll[collection_name].append(idx)
|
|
|
|
COLLECTIONS_INDEX = dict(coll)
|
|
CAN_BE_ASSIGNED_FROM_INDEX = dict(assigned_from)
|
|
|
|
def load_and_index_data():
|
|
"""Индексирует все данные для быстрого поиска"""
|
|
global ALL_ITEMS_WITH_ID, ITEMS_BY_TYPE, ITEMS_BY_RARITY, NAME_LOWER_TO_IDS
|
|
|
|
ALL_ITEMS_WITH_ID = []
|
|
ITEMS_BY_TYPE.clear()
|
|
ITEMS_BY_RARITY.clear()
|
|
NAME_LOWER_TO_IDS.clear()
|
|
|
|
print("Создание индексов...")
|
|
for i in tqdm(range(len(ALL_ITEMS)), desc="Индексация"):
|
|
item = ALL_ITEMS[i]
|
|
item_with_id = {**item, "id": i}
|
|
ALL_ITEMS_WITH_ID.append(item_with_id)
|
|
ITEM_BY_ID[i] = item
|
|
|
|
# По типу
|
|
t = item.get("type", "Normal")
|
|
ITEMS_BY_TYPE[t].append(item_with_id)
|
|
|
|
# По редкости
|
|
r = item.get("rarity", "Unknown")
|
|
ITEMS_BY_RARITY[r].append(item_with_id)
|
|
|
|
# Поиск по имени
|
|
name = item.get("market_hash_name", "").lower().strip()
|
|
if name:
|
|
NAME_LOWER_TO_IDS[name].append(i)
|
|
|
|
base_name = extract_base_name(item.get("market_hash_name", "")).lower().strip()
|
|
if base_name and base_name != name:
|
|
NAME_LOWER_TO_IDS[base_name].append(i)
|
|
|
|
# Строим индексы коллекций
|
|
build_indexes()
|
|
|
|
print(f"Готово. Уникальных имён для поиска: {len(NAME_LOWER_TO_IDS):,}")
|
|
print(f"Типов: {len(ITEMS_BY_TYPE)}")
|
|
print(f"Редкостей: {len(ITEMS_BY_RARITY)}")
|
|
print(f"Коллекций/кейсов: {len(COLLECTIONS_INDEX)}")
|
|
print(f"Источников (can_be_assigned_from): {len(CAN_BE_ASSIGNED_FROM_INDEX)}")
|
|
|
|
def warmup_and_debug_data():
|
|
"""Диагностика данных при старте"""
|
|
print("\n" + "="*80)
|
|
print("ПРОГРЕВ ДАННЫХ И ДИАГНОСТИКА")
|
|
print("="*80)
|
|
|
|
rarities = Counter()
|
|
types_cnt = Counter()
|
|
knife_count = 0
|
|
glove_count = 0
|
|
|
|
for item in ALL_ITEMS:
|
|
rarities[item.get("rarity", "Без редкости")] += 1
|
|
types_cnt[item.get("type", "Без типа")] += 1
|
|
|
|
if is_knife(item):
|
|
knife_count += 1
|
|
elif is_gloves(item):
|
|
glove_count += 1
|
|
|
|
print(f"Ножей: {knife_count:,}")
|
|
print(f"Перчаток: {glove_count:,}")
|
|
print(f"Rare Special Items всего: {knife_count + glove_count:,}")
|
|
|
|
print("\nРедкости:")
|
|
for r, c in sorted(rarities.items(), key=lambda x: x[1], reverse=True):
|
|
print(f" {r:24} → {c:6,}")
|
|
|
|
print("\nТипы:")
|
|
for t, c in types_cnt.most_common(5):
|
|
print(f" {t:24} → {c:6,}")
|
|
|
|
# Проверяем Covert предметы
|
|
covert_items = [it for it in ALL_ITEMS if it.get("rarity") == "Covert"]
|
|
covert_knives = [it for it in covert_items if is_knife(it)]
|
|
covert_skins = [it for it in covert_items if not is_knife(it) and not is_gloves(it)]
|
|
|
|
print(f"\nCovert предметы:")
|
|
print(f" Всего: {len(covert_items):,}")
|
|
print(f" Ножи: {len(covert_knives):,}")
|
|
print(f" Обычные скины: {len(covert_skins):,}")
|
|
|
|
# Проверяем кейсы
|
|
print(f"\nКейсов (Case: *): {sum(1 for k in COLLECTIONS_INDEX.keys() if k.startswith('Case:')):,}")
|
|
|
|
print("="*80 + "\n")
|
|
|
|
# Загружаем и индексируем данные
|
|
load_and_index_data()
|
|
warmup_and_debug_data()
|
|
|
|
def calculate_average_float(items: List[dict]) -> float:
|
|
"""Вычисляет средний float из входных предметов"""
|
|
if not items:
|
|
return 0.5
|
|
return sum(it.get("mid_float_used", 0.5) for it in items) / len(items)
|
|
|
|
def get_possible_outcomes(items: List[dict]) -> tuple[List[tuple], Dict[str, float]]:
|
|
"""
|
|
Определяет возможные исходы контракта и их вероятности.
|
|
Возвращает список (item_id, probability) и словарь весов источников.
|
|
"""
|
|
input_type = items[0].get("type", "Normal")
|
|
first_item = items[0]
|
|
|
|
# Определяем текущий уровень редкости
|
|
current_level = get_rarity_level(first_item.get("rarity"), first_item)
|
|
target_level = current_level + 1
|
|
|
|
# Собираем источники из входных предметов
|
|
source_counter = Counter()
|
|
|
|
for it in items:
|
|
# Из can_be_assigned_from
|
|
assigned = it.get("can_be_assigned_from", [])
|
|
if isinstance(assigned, list):
|
|
for source in assigned:
|
|
source_counter[source] += 1
|
|
elif isinstance(assigned, str) and assigned:
|
|
source_counter[assigned] += 1
|
|
|
|
# Из collection
|
|
coll = it.get("collection", "").strip()
|
|
if coll and coll != "Без коллекции":
|
|
source_counter[coll] += 1
|
|
|
|
# Из crates_names
|
|
crates = it.get("crates_names", [])
|
|
if crates and isinstance(crates, list):
|
|
for crate in crates:
|
|
source_counter[f"Case: {crate}"] += 1
|
|
|
|
total = sum(source_counter.values())
|
|
if total == 0:
|
|
return [], {}
|
|
|
|
# Веса источников пропорциональны количеству предметов из них
|
|
source_weights = {s: cnt / total for s, cnt in source_counter.items()}
|
|
|
|
# Группируем возможные исходы по базовому имени
|
|
base_to_ids = defaultdict(list)
|
|
|
|
for source_name in source_weights.keys():
|
|
# Проверяем в COLLECTIONS_INDEX
|
|
for idx in COLLECTIONS_INDEX.get(source_name, []):
|
|
cand = get_item(idx)
|
|
if not cand:
|
|
continue
|
|
|
|
# Проверяем уровень редкости
|
|
cand_level = get_rarity_level(cand.get("rarity"), cand)
|
|
if cand_level != target_level:
|
|
continue
|
|
|
|
# Проверяем тип
|
|
if cand.get("type") != input_type:
|
|
continue
|
|
|
|
base = extract_base_name(cand["market_hash_name"])
|
|
base_to_ids[base].append(idx)
|
|
|
|
# Проверяем в CAN_BE_ASSIGNED_FROM_INDEX
|
|
for idx in CAN_BE_ASSIGNED_FROM_INDEX.get(source_name, []):
|
|
cand = get_item(idx)
|
|
if not cand:
|
|
continue
|
|
|
|
cand_level = get_rarity_level(cand.get("rarity"), cand)
|
|
if cand_level != target_level:
|
|
continue
|
|
|
|
if cand.get("type") != input_type:
|
|
continue
|
|
|
|
base = extract_base_name(cand["market_hash_name"])
|
|
base_to_ids[base].append(idx)
|
|
|
|
if not base_to_ids:
|
|
return [], source_weights
|
|
|
|
# Равномерный шанс на каждый уникальный базовый скин
|
|
num_unique = len(base_to_ids)
|
|
prob_per_base = 1.0 / num_unique
|
|
|
|
possible_outcomes = []
|
|
for base, ids_list in base_to_ids.items():
|
|
prob_per_id = prob_per_base / len(ids_list)
|
|
for idx in ids_list:
|
|
possible_outcomes.append((idx, prob_per_id))
|
|
|
|
return possible_outcomes, source_weights
|
|
|
|
def simulate_trade_up(items: List[dict]) -> TradeUpSimulationResult:
|
|
"""Симулирует контракт обмена 10→1"""
|
|
# Подготовка input_items
|
|
input_short = []
|
|
for it in items:
|
|
if isinstance(it, dict):
|
|
enriched = it.copy()
|
|
else:
|
|
enriched = {}
|
|
|
|
if "id" not in enriched:
|
|
enriched["id"] = -1
|
|
|
|
try:
|
|
short = ItemShort(**enriched)
|
|
input_short.append(short)
|
|
except Exception:
|
|
input_short.append(ItemShort(
|
|
id=enriched.get("id", -1),
|
|
market_hash_name=enriched.get("market_hash_name", "???")
|
|
))
|
|
|
|
ok, msg = can_be_upgraded(items)
|
|
if not ok:
|
|
return TradeUpSimulationResult(
|
|
success=False,
|
|
input_items=input_short,
|
|
received_item=None,
|
|
received_float=None,
|
|
probability=None,
|
|
message=msg
|
|
)
|
|
|
|
possible_outcomes, source_weights = get_possible_outcomes(items)
|
|
|
|
if not possible_outcomes:
|
|
input_type = items[0].get("type", "Normal")
|
|
first_item = items[0]
|
|
current_level = get_rarity_level(first_item.get("rarity"), first_item)
|
|
target_level = current_level + 1
|
|
|
|
# Определяем название целевой редкости
|
|
rarity_names = {v: k for k, v in RARITY_ORDER.items()}
|
|
target_rarity_name = rarity_names.get(target_level, f"уровень {target_level}")
|
|
|
|
return TradeUpSimulationResult(
|
|
success=False,
|
|
input_items=input_short,
|
|
received_item=None,
|
|
received_float=None,
|
|
probability=None,
|
|
message=f"Нет {input_type} предметов редкостью {target_rarity_name} в выбранных источниках"
|
|
)
|
|
|
|
# Выбираем случайный предмет
|
|
indices = [o[0] for o in possible_outcomes]
|
|
weights = [o[1] for o in possible_outcomes]
|
|
|
|
chosen_idx = random.choices(indices, weights=weights, k=1)[0]
|
|
received = get_item(chosen_idx)
|
|
|
|
# Вычисляем float (среднее арифметическое входных float)
|
|
avg_float = calculate_average_float(items)
|
|
|
|
# Находим вероятность выпавшего предмета
|
|
probability = next((p for idx, p in possible_outcomes if idx == chosen_idx), 0)
|
|
|
|
return TradeUpSimulationResult(
|
|
success=True,
|
|
input_items=input_short,
|
|
received_item=ItemShort(**{**received, "id": chosen_idx}),
|
|
received_float=round(avg_float, 4),
|
|
probability=round(probability * 100, 2),
|
|
message="Контракт успешен"
|
|
)
|
|
|
|
# ─── Эндпоинты ─────────────────────────────────────────────────────────────
|
|
@app.get("/items")
|
|
def get_all_items(
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
search: Optional[str] = Query(None),
|
|
id: Optional[int] = Query(None),
|
|
item_type: Optional[str] = Query(None),
|
|
rarity: Optional[str] = Query(None),
|
|
min_price: Optional[float] = Query(None, ge=0),
|
|
max_price: Optional[float] = Query(None, ge=0),
|
|
wear: Optional[str] = Query(None),
|
|
):
|
|
"""Возвращает список предметов с фильтрацией и поиском"""
|
|
if id is not None:
|
|
item = get_item(id)
|
|
if item is None:
|
|
return []
|
|
return [ItemShort(**{**item, "id": id})]
|
|
|
|
# Базовый набор кандидатов
|
|
if item_type and item_type.lower() != "all":
|
|
candidates = ITEMS_BY_TYPE.get(item_type, [])
|
|
else:
|
|
candidates = ALL_ITEMS_WITH_ID
|
|
|
|
# Фильтр по редкости
|
|
if rarity:
|
|
r_norm = rarity.strip()
|
|
candidates = [it for it in candidates if it.get("rarity", "").strip() == r_norm]
|
|
|
|
# Поиск по имени
|
|
if search:
|
|
query = search.lower().strip()
|
|
found_ids = set()
|
|
|
|
# Точное совпадение
|
|
if query in NAME_LOWER_TO_IDS:
|
|
found_ids.update(NAME_LOWER_TO_IDS[query])
|
|
|
|
# Частичное совпадение по словам
|
|
search_words = query.split()
|
|
for name_lower, ids in NAME_LOWER_TO_IDS.items():
|
|
if all(word in name_lower for word in search_words):
|
|
found_ids.update(ids)
|
|
elif name_lower.startswith(query):
|
|
found_ids.update(ids)
|
|
|
|
if not found_ids:
|
|
return []
|
|
candidates = [it for it in candidates if it["id"] in found_ids]
|
|
|
|
# Фильтры по цене и wear
|
|
filtered = candidates
|
|
if min_price is not None or max_price is not None:
|
|
filtered = [
|
|
it for it in filtered
|
|
if it.get("price_rub") is not None and
|
|
(min_price is None or it["price_rub"] >= min_price) and
|
|
(max_price is None or it["price_rub"] <= max_price)
|
|
]
|
|
|
|
if wear:
|
|
wear_norm = wear.strip()
|
|
filtered = [it for it in filtered if it.get("wear", "").strip() == wear_norm]
|
|
|
|
# Пагинация
|
|
sliced = filtered[skip:skip + limit]
|
|
return [ItemShort(**it) for it in sliced]
|
|
|
|
@app.get("/filters")
|
|
def get_filters():
|
|
"""Возвращает доступные значения для фильтров"""
|
|
rarities = set()
|
|
types = set()
|
|
wears = set()
|
|
price_range = {"min": float('inf'), "max": float('-inf')}
|
|
|
|
knife_count = 0
|
|
glove_count = 0
|
|
normal_count = 0
|
|
|
|
for item in ALL_ITEMS:
|
|
if item.get("rarity"):
|
|
rarities.add(item["rarity"])
|
|
if item.get("type"):
|
|
types.add(item["type"])
|
|
if item.get("wear"):
|
|
wears.add(item["wear"])
|
|
if item.get("price_rub"):
|
|
price = item["price_rub"]
|
|
if price < price_range["min"]:
|
|
price_range["min"] = price
|
|
if price > price_range["max"]:
|
|
price_range["max"] = price
|
|
|
|
if is_knife(item):
|
|
knife_count += 1
|
|
elif is_gloves(item):
|
|
glove_count += 1
|
|
else:
|
|
normal_count += 1
|
|
|
|
return {
|
|
"rarities": sorted(list(rarities)),
|
|
"types": sorted(list(types)),
|
|
"wears": sorted(list(wears)),
|
|
"price_range": {
|
|
"min_rub": price_range["min"] if price_range["min"] != float('inf') else 0,
|
|
"max_rub": price_range["max"] if price_range["max"] != float('-inf') else 0
|
|
},
|
|
"statistics": {
|
|
"total_items": len(ALL_ITEMS),
|
|
"knives": knife_count,
|
|
"gloves": glove_count,
|
|
"normal_skins": normal_count,
|
|
"note": "Ножи и перчатки нельзя использовать в trade-up контрактах"
|
|
}
|
|
}
|
|
|
|
@app.post("/contract/submit", response_model=TradeUpSimulationResult)
|
|
def submit_contract(req: ContractSubmitRequest):
|
|
"""Выполняет симуляцию контракта обмена"""
|
|
try:
|
|
selected = []
|
|
for idx in req.item_ids:
|
|
item = get_item(idx)
|
|
if item is None:
|
|
raise HTTPException(400, detail=f"id {idx} не найден")
|
|
item_with_id = item.copy()
|
|
item_with_id["id"] = idx
|
|
selected.append(item_with_id)
|
|
return simulate_trade_up(selected)
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
raise HTTPException(500, detail=str(e))
|
|
|
|
@app.get("/collections/")
|
|
def get_collections_list():
|
|
"""Возвращает список всех коллекций и источников"""
|
|
result = []
|
|
|
|
all_sources = set(COLLECTIONS_INDEX.keys()) | set(CAN_BE_ASSIGNED_FROM_INDEX.keys())
|
|
|
|
for name in sorted(all_sources):
|
|
ids = set()
|
|
if name in COLLECTIONS_INDEX:
|
|
ids.update(COLLECTIONS_INDEX[name])
|
|
if name in CAN_BE_ASSIGNED_FROM_INDEX:
|
|
ids.update(CAN_BE_ASSIGNED_FROM_INDEX[name])
|
|
|
|
unique_bases = set()
|
|
for idx in ids:
|
|
item = get_item(idx)
|
|
if item:
|
|
base = extract_base_name(item.get("market_hash_name", ""))
|
|
if base:
|
|
unique_bases.add(base)
|
|
|
|
result.append({
|
|
"name": name,
|
|
"total_items": len(ids),
|
|
"unique_skins": len(unique_bases),
|
|
"is_case": name.startswith("Case: ")
|
|
})
|
|
|
|
return {
|
|
"total_collections": len(result),
|
|
"collections": result
|
|
}
|
|
|
|
@app.get("/collections/all")
|
|
def get_collection_names():
|
|
"""Возвращает подробную информацию о всех коллекциях"""
|
|
def rarity_key(idx: int) -> int:
|
|
item = get_item(idx)
|
|
return get_rarity_level(item.get("rarity"), item) if item else -1
|
|
|
|
result = []
|
|
all_sources = set(COLLECTIONS_INDEX.keys()) | set(CAN_BE_ASSIGNED_FROM_INDEX.keys())
|
|
|
|
for source_name in sorted(all_sources):
|
|
ids = set()
|
|
if source_name in COLLECTIONS_INDEX:
|
|
ids.update(COLLECTIONS_INDEX[source_name])
|
|
if source_name in CAN_BE_ASSIGNED_FROM_INDEX:
|
|
ids.update(CAN_BE_ASSIGNED_FROM_INDEX[source_name])
|
|
|
|
base_names = set()
|
|
items_info = []
|
|
|
|
for i in ids:
|
|
item = get_item(i)
|
|
if item:
|
|
base = extract_base_name(item["market_hash_name"])
|
|
if base:
|
|
base_names.add(base)
|
|
|
|
items_info.append({
|
|
"id": i,
|
|
"name": item.get("market_hash_name", "Unknown"),
|
|
"rarity": item.get("rarity", "Unknown"),
|
|
"type": item.get("type", "Normal"),
|
|
"wear": item.get("wear", "Unknown"),
|
|
"price_rub": item.get("price_rub"),
|
|
"price_usd": item.get("price_usd"),
|
|
"is_craftable": item.get("is_craftable", True)
|
|
})
|
|
|
|
sorted_ids = sorted(ids, key=rarity_key)
|
|
items_info.sort(key=lambda x: (
|
|
get_rarity_level(x["rarity"], get_item(x["id"])),
|
|
x["name"]
|
|
))
|
|
|
|
result.append({
|
|
"name": source_name,
|
|
"unique_skin_count": len(base_names),
|
|
"total_variants": len(ids),
|
|
"item_ids_by_rarity": sorted_ids,
|
|
"items": items_info
|
|
})
|
|
|
|
return result
|
|
|
|
@app.get("/collection/{name}", response_model=CollectionDetail)
|
|
def get_one_collection(name: str):
|
|
"""Возвращает детальную информацию о конкретной коллекции/источнике"""
|
|
ids = set()
|
|
if name in COLLECTIONS_INDEX:
|
|
ids.update(COLLECTIONS_INDEX[name])
|
|
if name in CAN_BE_ASSIGNED_FROM_INDEX:
|
|
ids.update(CAN_BE_ASSIGNED_FROM_INDEX[name])
|
|
|
|
if not ids:
|
|
raise HTTPException(404, detail="Коллекция или источник не найдены")
|
|
|
|
groups = defaultdict(list)
|
|
for idx in ids:
|
|
item = get_item(idx)
|
|
if not item:
|
|
continue
|
|
base = extract_base_name(item["market_hash_name"])
|
|
if not base:
|
|
continue
|
|
groups[base].append((
|
|
item.get("mid_float_used", 999.9),
|
|
idx,
|
|
item.get("type", "Normal"),
|
|
item.get("rarity", "Unknown")
|
|
))
|
|
|
|
result_items = []
|
|
for base_name, variants in sorted(groups.items()):
|
|
rarity = next((v[3] for v in variants if v[3] != "Unknown"), "Unknown")
|
|
normal = [v[1] for v in sorted(variants) if v[2] == "Normal"]
|
|
stattrak = [v[1] for v in sorted(variants) if v[2] == "StatTrak"]
|
|
souvenir = [v[1] for v in sorted(variants) if v[2] == "Souvenir"]
|
|
result_items.append(CollectionItemBase(
|
|
base_name=base_name,
|
|
rarity=rarity,
|
|
item_ids_normal=normal,
|
|
item_ids_stattrak=stattrak,
|
|
item_ids_souvenir=souvenir,
|
|
count_variants=len(variants)
|
|
))
|
|
|
|
return CollectionDetail(
|
|
collection=name,
|
|
total_unique_skins=len(result_items),
|
|
items=result_items
|
|
)
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
"""Проверка работоспособности сервера"""
|
|
return {
|
|
"status": "ok",
|
|
"items_loaded": len(ALL_ITEMS),
|
|
"base_items": len(BASE_ITEMS),
|
|
"custom_items": len(CUSTOM_ITEMS)
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=13668) |