Craft System
1. Summary
Goal: Система крафта скинов из материалов (FRAGMENT, BLUEPRINT, RESOURCE). Финальный шаг геймификации: собранные из кейсов компоненты конвертируются в реальный скин для вывода в Steam.
User Value: Возможность собрать желаемый скин, а не надеяться на RNG. Путь: Кейсы → Материалы → Крафт → Скин → Вывод в Steam.
2. Business Logic
Craft Materials
- Fragment
- Blueprint
- Resource
Тип: Осколок скина
Получение: Выпадает из кейсов (с буст-шансом в Luck Pool)
Tier: Наследуется от targetSkin
Цель: Основной материал для крафта
Тип: Чертёж скина
Получение: Выпадает из кейсов (с буст-шансом в Luck Pool)
Tier: Наследуется от targetSkin
Цель: Рецепт крафта (обязательный компонент)
Тип: Универсальный ресурс
Получение: Выпадает из кейсов
Примеры: Metal, Cloth, HQMetal
Цель: Дополнительный материал
Craft Recipe
Каждый скин (Item с itemType: SKIN) имеет рецепт крафта в поле craftRecipe (JSON):
{
"materials": [
{ "itemId": "blueprint-ak47-redline", "quantity": 1 },
{ "itemId": "fragment-ak47-redline", "quantity": 5 },
{ "itemId": "resource-hq-metal", "quantity": 10 }
]
}
Стоимость: Дополнительно списывается craftScrapCost Scrap.
Craft Flow
1. Validation
- Предмет существует и
isActive: true itemType: SKIN(только скины крафтятся)- Есть
craftRecipe(не null)
2. Resource Check
- Достаточно Scrap (
user.scrap >= craftScrapCost) - Все материалы в инвентаре (сумма по всем
sourceType)
3. Atomic Transaction
Всё выполняется в одной транзакции $transaction:
- Списание Scrap с User
- Удаление материалов из
UserInventory(меньшие стеки первыми) - Добавление скрафченного скина в инвентарь (
sourceType: CRAFTING) - Создание записи в
CraftHistory - Обновление статистики Item (
totalCrafted) - Создание
FeedEvent(ITEM_CRAFTED)
4. Budget Control Integration
Если скин имеет priceRub, крафт записывается в CraftBudgetLog и влияет на Budget Control:
- Expense записывается в текущий
BudgetPeriod - Если игрок был в Luck Pool, происходит выход из пула
- Скины с прогрессом ≥50% блокируются до конца месяца
5. Anti-Fraud Check
При >10 крафтов за 24ч отправляется уведомление админам (suspicious activity).
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| Check craft | general (100/min) | Telegram | CheckCraftAbilitySchema |
| Craft item | mutations (5/min) | Telegram + Active Season | CraftItemSchema |
| Get craftable | general (100/min) | Telegram | GetCraftableItemsSchema |
См. Security Matrix для полного обзора защит.
Edge Cases
Что видит пользователь (UI):
| Ситуация | UI поведение |
|---|---|
| ❌ Недостаточно Scrap | Кнопка disabled, tooltip "Недостаточно N Scrap" |
| ❌ Недостаточно материалов | Показан прогресс сбора: "3/5 осколков" |
| ⛔ Сезон не активен | Кнопка disabled, toast "Сезон завершён" |
| ✅ Крафт успешен | Анимация + скин в инвентаре |
Backend Error Codes (для API/тестов)
| Код | HTTP | Сообщение пользователю |
|---|---|---|
INSUFFICIENT_SCRAP | 400 | "Недостаточно Scrap для крафта" |
MISSING_MATERIALS | 400 | "Недостаточно материалов для крафта" |
ITEM_NOT_FOUND | 404 | "Предмет не найден" |
NOT_CRAFTABLE | 400 | "Предмет нельзя скрафтить" |
INVALID_RECIPE | 400 | "Некорректный рецепт крафта" |
ITEM_NOT_ACTIVE | 400 | "Предмет неактивен" |
USER_NOT_FOUND | 404 | "Пользователь не найден" |
ITEM_NOT_SKIN | 400 | "Крафтить можно только скины" |
3. ADR (Architectural Decisions)
Почему JSON рецепт, а не реляционная таблица?
Проблема: Нужна гибкая настройка рецептов крафта через админку.
Решение: Рецепт хранится как JSON в Item.craftRecipe.
Альтернативы (отклонены):
CraftRecipe+CraftIngredientтаблицы — избыточная сложность, рецепт фиксирован на момент крафта- Hardcoded рецепты — нельзя менять без деплоя
Последствия: Простота изменения, но требуется валидация JSON при каждом использовании.
Почему атомарные транзакции?
При крафте нужно одновременно списать материалы, списать Scrap И создать скин. Если падает между операциями — потеря данных.
Решение: Prisma $transaction для atomic operations.
Последствия: Гарантированная консистентность, блокировка на время транзакции.
Почему Seniority сбрасывается после крафта?
Проблема: Игрок с максимальным Seniority (×12.9 буст) может накопить материалы и крафтить подряд несколько скинов с бустом.
Решение: После крафта activePeriods сбрасывается до 1, игрок выходит из пула на период.
Альтернативы (отклонены):
- Не сбрасывать — злоупотребление бустом
- Полный бан из пула — слишком жёстко
Последствия: Справедливое распределение буста между активными игроками.
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| CraftService | backend/src/domains/craft/services/craft.service.ts | Основная бизнес-логика |
| CraftController | backend/src/domains/craft/controllers/user-craft.controller.ts | HTTP handlers |
| Routes | backend/src/domains/craft/routes/user-crafts.routes.ts | User API endpoints |
| Schemas | backend/src/domains/craft/schemas/user-crafts.schemas.ts | Валидация запросов/ответов |
| Types | backend/src/domains/craft/types/craft.types.ts | TypeScript типы и error codes |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| Item | Предмет (скин или материал) | craftRecipe, craftScrapCost, itemType, tier |
| CraftHistory | История крафта | userId, itemId, spentScrap, materialsUsed |
| CraftBudgetLog | Лог для Budget Control | skinId, costRub, hadBoost, totalBoostMultiplier |
Relationships
Структура CraftHistory
| Поле | Тип | Описание |
|---|---|---|
id | String | CUID |
userId | String | Кто скрафтил |
itemId | String | Какой скин получен |
spentScrap | Int | Потрачено Scrap |
materialsUsed | JSON | { itemId: quantity } |
gainedXP | Int | XP за крафт (сейчас 0) |
craftedAt | DateTime | Когда |
Структура CraftBudgetLog
| Поле | Тип | Описание |
|---|---|---|
id | String | CUID |
userId | String | Кто скрафтил |
skinId | String | ID скина |
periodId | String | ID бюджетного периода |
tier | ItemTier | Редкость скина |
costRub | Float | Стоимость в рублях |
activePeriods | Int | Стаж в пуле на момент крафта |
hadBoost | Boolean | Был ли буст |
seniorityMultiplier | Float | Множитель стажа (1.0-4.3) |
totalBoostMultiplier | Float | Итоговый буст (3.0-12.9) |
blockedSkinIds | String[] | Скины ≥50% на момент крафта |
craftedAt | DateTime | Когда |
6. API Endpoints
- User API
Response Examples
GET /api/craft/check/:itemId
{
"success": true,
"data": {
"canCraft": false,
"reason": "Insufficient materials: AK-47 Fragment (need 5, have 3)",
"requirements": {
"scrapCost": 500,
"materials": [
{
"itemId": "fragment-ak47",
"itemName": "AK-47 Fragment",
"itemImageUrl": "https://...",
"itemType": "FRAGMENT",
"itemTier": "TIER_3",
"needed": 5,
"available": 3
}
]
},
"userResources": {
"scrap": 1500,
"materials": {
"fragment-ak47": 3,
"blueprint-ak47": 1
}
}
}
}
POST /api/craft/:itemId
{
"success": true,
"data": {
"craftedItem": {
"id": "inv-123",
"itemId": "skin-ak47-redline",
"quantity": 1,
"sourceType": "CRAFTING",
"item": {
"id": "skin-ak47-redline",
"name": "AK-47 | Redline",
"tier": "TIER_3",
"itemType": "SKIN"
}
},
"newScrapBalance": 1000,
"xpGained": 0
}
}
GET /api/craft/available
{
"success": true,
"data": [
{
"item": {
"id": "skin-ak47-redline",
"name": "AK-47 | Redline",
"tier": "TIER_3",
"craftScrapCost": 500
},
"canCraft": true,
"dropSources": ["Weapon Case", "AK-47 Collection"]
},
{
"item": {
"id": "skin-awp-dragon-lore",
"name": "AWP | Dragon Lore",
"tier": "TIER_5",
"craftScrapCost": 5000
},
"canCraft": false,
"missingRequirements": {
"scrap": 3500,
"materials": [
{ "itemId": "blueprint-awp-dl", "itemName": "AWP DL Blueprint", "missing": 1 }
]
},
"dropSources": ["Legendary Case"]
}
]
}
7. Related
- Cases — источник материалов (FRAGMENT, BLUEPRINT, RESOURCE)
- Inventory — хранение материалов и скрафченных скинов
- Luck Pool — буст вероятности материалов для активных игроков
- Budget — контроль расходов на крафт
- Seasons — крафт требует активного сезона
- Live Feed — событие
ITEM_CRAFTEDв публичной ленте