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.
Admin Drop Override
Админский инструмент для точечной настройки шансов выпадения материалов конкретного скина для конкретного пользователя.
Модель: AdminDropOverride — один оверрайд на (userId, skinId, seasonId).
Множитель: 0.25 ≤ multiplier ≤ 10.0
< 1.0— нерф (снижение шансов)= 1.0— нет эффекта (оверрайд удаляется)> 1.0— буст (увеличение шансов)
Формула весов (с оверрайдом):
weight = base × boost(luckPool) × penalty(budget) × adminOverride
Все множители стекаются (перемножаются). Admin override — независимый множитель, применяется ПОСЛЕ boost и budget penalty.
Привязка к сезону: Оверрайды не переносятся между сезонами.
Невидимость: User-facing API не возвращает информацию об оверрайдах. Логирование — debug level only.
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
При >2 крафтов за 24ч (т.е. 3+ крафтов) отправляется уведомление админам (suspicious activity). Порог: BUDGET_CONTROL.ADMIN.SUSPICIOUS_CRAFTS_THRESHOLD = 2.
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| Check craft | general (100/min) | Telegram | CheckCraftAbilitySchema |
| Craft item | mutations (15/min) | Telegram + Active Season | CraftItemSchema |
| Get craftable | general (100/min) | Telegram | GetCraftableItemsSchema |
| Skin progress summary | adminApiLimiter | Admin JWT | GetSkinProgressSummarySchema |
| Skin progress users | adminApiLimiter | Admin JWT | GetSkinProgressUsersSchema |
| Get drop overrides | adminApiLimiter | Admin JWT | GetDropOverridesSchema |
| Upsert drop override | adminApiLimiter | Admin JWT | UpsertDropOverrideSchema |
| Delete drop override | adminApiLimiter | Admin JWT | DeleteDropOverrideSchema |
См. Security Matrix для полного обзора защит.
Edge Cases
Что видит пользователь (UI):
| Ситуация | UI поведение |
|---|---|
| ❌ Недостаточно Scrap | Кнопка disabled, tooltip "Недостаточно N Scrap" |
| ❌ Недостаточно материалов | Показан прогресс сбора: "3/5 осколков" |
| ⛔ Сезон не активен | Кнопка disabled, toast "Сезон завершён" |
| ✅ Крафт успешен | Анимация + скин в инвентаре |
| 🔧 Scrap-only progress (Admin) | Юзер без материалов, но с scrap ≥ craftScrapCost — прогресс > 0 только от scrap-компонента |
| 🔧 Deleted users (Admin) | Фильтруются по deletedAt: null, не отображаются в skin progress |
| 🔧 Invalid craftRecipe JSON (Admin) | Скин пропускается в summary, логируется warning |
| 🔧 Override multiplier = 1.0 (Admin) | Оверрайд удаляется (DELETE), а не сохраняется — нет эффекта |
| 🔧 Нет активного сезона для override (Admin) | Ошибка 400 при попытке upsert/get оверрайда |
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 | "Крафтить можно только скины" |
INVALID_CRAFT_COST | 400 | "Некорректная стоимость крафта" |
3. ADR (Architectural Decisions)
Почему JSON рецепт, а не реляционная таблица?
Проблема: Нужна гибкая настройка рецептов крафта через админку.
Решение: Рецепт хранится как JSON в Item.craftRecipe.
Альтернативы (отклонены):
CraftRecipe+CraftIngredientтаблицы — избыточная сложность, рецепт фиксирован на момент крафта- Hardcoded рецепты — нельзя менять без деплоя
Последствия: Простота изменения, но требуется валидация JSON при каждом использовании.
Почему атомарные транзакции?
При крафте нужно одновременно списать материалы, списать Scrap И создать скин. Если падает между операциями — потеря данных.
Решение: Prisma $transaction для atomic operations.
Последствия: Гарантированная консистентность, блокировка на время транзакции.
Почему Seniority сбрасывается после крафта?
Проблема: Игрок с максимальным Seniority (×12.9 буст) может накопить материалы и крафтить подряд несколько скинов с бустом.
Решение: После крафта activePeriods сбрасывается до 1, игрок выходит из пула на период.
Альтернативы (отклонены):
- Не сбрасывать — злоупотребление бустом
- Полный бан из пула — слишком жёстко
Последствия: Справедливое распределение буста между активными игроками.
Почему Admin Drop Override — отдельный множитель, а не модификация Luck Pool boost?
Проблема: Админ хочет точечно управлять шансами дропа для конкретного пользователя на конкретный скин. Luck Pool boost — автоматический, зависит от активности, нельзя контролировать точечно.
Решение: Независимый множитель adminOverride, который стекается мультипликативно с boost и budget penalty. Отдельная модель AdminDropOverride, отдельный сервис.
Альтернативы (отклонены):
- Модификация
boostMultiplierв Luck Pool — смешивает автоматику и ручное управление, теряется аудит - Отдельная таблица весов — overengineering для точечного инструмента
Последствия: Чистое разделение автоматического буста (Luck Pool) и ручного управления (Admin Override). Без оверрайдов — поведение идентично текущему (пустой Map → множитель не применяется).
Почему без кеша для Admin Drop Override?
Проблема: При каждом открытии кейса/спина нужно проверить наличие оверрайдов.
Решение: Прямой запрос к БД (~1мс по индексу [userId, seasonId, isActive]).
Альтернативы (отклонены):
- Redis кеш — инвалидация сложнее, чем сам запрос
- In-memory кеш — проблемы с масштабированием, stale data
Последствия: Простота, гарантированная актуальность данных, минимальный overhead.
Почему отдельный SkinProgressService?
Проблема: CraftService уже ~1150 строк. Admin-аналитика прогресса крафта — отдельная ответственность (read-only, агрегация данных), не относится к user-facing craft flow.
Решение: Выделить SkinProgressService — admin-only аналитический сервис. Рассчитывает прогресс сбора материалов для каждого скина по всем пользователям.
Оптимизация: 4 SQL-запроса вместо N+1 (~20 скинов × ~5000 юзеров):
item.findMany— все craftable SKINsuserInventory.groupBy([userId, itemId], _sum: quantity)— материалы всех юзеровuser.findMany(select: {id, scrap})— scrap балансы- In-memory — расчёт прогресса и агрегация
Формула прогресса (зеркалит frontend calculateCraftProgress):
progress = Σ(min(available / needed, 1)) / totalWeight
totalWeight = materials.length + (craftScrapCost > 0 ? 1 : 0)
Альтернативы (отклонены):
- Добавить в CraftService — нарушение SRP, файл и так перегружен
- Materialized view в БД — преждевременная оптимизация, in-memory достаточно для ~20 скинов
Последствия: Чистое разделение user craft flow и admin analytics. Формула прогресса синхронизирована с frontend.
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 |
| SkinProgressService | backend/src/domains/craft/services/skin-progress.service.ts | Admin-аналитика прогресса крафта |
| AdminCraftController | backend/src/domains/craft/controllers/admin-craft.controller.ts | Admin HTTP handlers |
| Admin Routes | backend/src/domains/craft/routes/admin-craft.routes.ts | Admin API endpoints |
| AdminCraftSchemas | backend/src/domains/craft/schemas/admin-craft.schemas.ts | Admin валидация (GetSkinProgressSummarySchema, GetSkinProgressUsersSchema) |
| AdminDropOverrideService | backend/src/domains/craft/services/admin-drop-override.service.ts | CRUD оверрайдов + getActiveOverridesForUser для drop flow |
| AdminDropOverrideSchemas | backend/src/domains/craft/schemas/admin-drop-override.schemas.ts | Валидация drop override endpoints |
| DropOverrideTypes | backend/src/domains/craft/types/drop-override.types.ts | AdminDropOverrideDTO, DropOverrideMap |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| Item | Предмет (скин или материал) | craftRecipe, craftScrapCost, itemType, tier |
| CraftHistory | История крафта | userId, itemId, spentScrap, materialsUsed |
| CraftBudgetLog | Лог для Budget Control | skinId, costRub, hadBoost, totalBoostMultiplier |
| AdminDropOverride | Админский оверрайд шанса дропа | userId, skinId, seasonId, multiplier, isActive |
Relationships
Структура AdminDropOverride
| Поле | Тип | Описание |
|---|---|---|
id | String | CUID |
userId | String | Пользователь, на которого действует оверрайд |
skinId | String | Скин, материалы которого затронуты |
seasonId | String | Сезон (оверрайд привязан к сезону) |
multiplier | Float | Множитель веса: 0.25–10.0. Значение 1.0 = нет эффекта |
reason | String? | Опциональная причина (для аудита между админами) |
isActive | Boolean | Можно деактивировать без удаления |
Unique constraint: @@unique([userId, skinId, seasonId])
Index: @@index([userId, seasonId, isActive])
Структура 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
- Admin API
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /api/craft/check/:itemId | Проверка возможности крафта | → |
| POST | /api/craft/:itemId | Выполнить крафт | → |
| GET | /api/craft/available | Список всех крафтовых предметов | → |
| Метод | Эндпоинт | Описание |
|---|---|---|
| GET | /admin/craft/skin-progress | Сводка прогресса по всем крафтабельным скинам |
| GET | /admin/craft/skin-progress/:skinId/users | Пользователи конкретного скина с детализацией + Luck Pool info |
| GET | /admin/craft/drop-overrides/:skinId | Все оверрайды дропа для скина в текущем сезоне |
| PUT | /admin/craft/drop-overrides | Создать или обновить оверрайд (upsert) |
| DELETE | /admin/craft/drop-overrides/:userId/:skinId | Удалить оверрайд |
Response Examples
GET /api/craft/check/:itemId
{
"success": true,
"data": {
"canCraft": false,
"reason": "Insufficient materials: AK-47 Fragment (need 5, have 3)",
"requirements": {
"scrap": {
"needed": 500,
"available": 1500
},
"materials": [
{
"itemId": "fragment-ak47",
"itemName": "AK-47 Fragment",
"itemImageUrl": "https://...",
"itemType": "FRAGMENT",
"itemTier": "TIER_3",
"needed": 5,
"available": 3,
"dropSources": [
{ "sourceType": "case", "sourceId": "case-weapon", "sourceName": "Weapon Case", "sourceImageUrl": "https://...", "sourcePrice": 100, "sourceCurrencyType": "SCRAP" }
]
},
{
"itemId": "blueprint-ak47",
"itemName": "AK-47 Blueprint",
"itemImageUrl": "https://...",
"itemType": "BLUEPRINT",
"itemTier": "TIER_3",
"needed": 1,
"available": 1,
"dropSources": [
{ "sourceType": "case", "sourceId": "case-weapon", "sourceName": "Weapon Case", "sourceImageUrl": "https://...", "sourcePrice": 100, "sourceCurrencyType": "SCRAP" }
]
}
]
}
}
}
Тип ответа: CraftCheckResult из craft.types.ts. Scrap и материалы объединены в requirements (интерфейс CraftRequirements). Каждый материал содержит dropSources с источниками получения.
dropSourcesзаполняется только для BLUEPRINT и FRAGMENT материалов. Для RESOURCE — всегда пустой массив.sourceTypeможет быть"case"(кейс) или"spin"(рулетка DailySpin). Тип определён какMaterialDropSourceType = 'case' | 'spin'вcraft.types.ts.- Каждый источник содержит:
sourceImageUrl(картинка),sourcePrice(цена) иsourceCurrencyType("SCRAP"или"STREAK_POINTS").
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",
"imageUrl": "https://..."
// ...полный Item (все поля Prisma модели)
}
},
"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,
"itemType": "SKIN",
"imageUrl": "https://..."
// ...полный Item (все поля Prisma модели)
},
"canCraft": true,
"requirements": {
"scrap": {
"needed": 500,
"available": 1500
},
"materials": [
{
"itemId": "fragment-ak47",
"itemName": "AK-47 Fragment",
"itemImageUrl": "https://...",
"itemType": "FRAGMENT",
"itemTier": "TIER_3",
"needed": 5,
"available": 5,
"dropSources": [
{ "sourceType": "case", "sourceId": "case-weapon", "sourceName": "Weapon Case", "sourceImageUrl": "https://...", "sourcePrice": 100, "sourceCurrencyType": "SCRAP" }
]
}
]
}
},
{
"item": {
"id": "skin-awp-dragon-lore",
"name": "AWP | Dragon Lore",
"tier": "TIER_5",
"craftScrapCost": 5000,
"itemType": "SKIN",
"imageUrl": "https://..."
// ...полный Item (все поля Prisma модели)
},
"canCraft": false,
"requirements": {
"scrap": {
"needed": 5000,
"available": 1500
},
"materials": [
{
"itemId": "blueprint-awp-dl",
"itemName": "AWP DL Blueprint",
"itemImageUrl": "https://...",
"itemType": "BLUEPRINT",
"itemTier": "TIER_5",
"needed": 1,
"available": 0,
"dropSources": [
{ "sourceType": "case", "sourceId": "case-legendary", "sourceName": "Legendary Case", "sourceImageUrl": "https://...", "sourcePrice": 500, "sourceCurrencyType": "SCRAP" }
]
}
]
}
}
]
}
Тип ответа: CraftableItemInfo[] из craft.types.ts. Каждый элемент содержит item, canCraft и requirements (интерфейс CraftRequirements). Источники получения материалов (dropSources) вложены в каждый элемент materials[].
GET /admin/craft/skin-progress
{
"success": true,
"data": [
{
"skinId": "clx...",
"skinName": "AK-47 Redline",
"skinImageUrl": "https://...",
"skinTier": "TIER_3",
"skinCategory": "WEAPON",
"materialsCount": 3,
"craftScrapCost": 500,
"usersWithProgress": 42,
"usersCanCraft": 2,
"maxProgress": 0.85,
"avgProgress": 0.34,
"totalCrafted": 5
}
]
}
Admin-only аналитика. Для каждого крафтабельного скина: количество пользователей с прогрессом, максимальный и средний прогресс, сколько могут скрафтить прямо сейчас. Сортировка по tier DESC.
GET /admin/craft/skin-progress/:skinId/users
{
"success": true,
"data": {
"skin": {
"id": "clx...",
"name": "AK-47 Redline",
"imageUrl": "https://...",
"tier": "TIER_3",
"craftScrapCost": 500
},
"users": [
{
"userId": "clx...",
"username": "player1",
"telegramId": "123456",
"progress": 0.85,
"canCraft": false,
"scrap": { "needed": 500, "available": 400 },
"materials": [
{ "itemId": "bp-1", "itemName": "AK-47 Blueprint", "itemImageUrl": "...", "itemType": "BLUEPRINT", "needed": 1, "available": 1, "missing": 0 },
{ "itemId": "frag-1", "itemName": "AK-47 Fragment", "itemImageUrl": "...", "itemType": "FRAGMENT", "needed": 5, "available": 3, "missing": 2 }
]
}
],
"pagination": { "page": 1, "limit": 20, "total": 42, "totalPages": 3 }
}
}
Детализация по конкретному скину: список пользователей с прогрессом > 0, их материалы и scrap. Пагинация in-memory (slice). Сортировка по progress DESC.
7. Related
- Cases — источник материалов (FRAGMENT, BLUEPRINT, RESOURCE)
- Inventory — хранение материалов и скрафченных скинов
- Luck Pool — буст вероятности материалов для активных игроков
- Budget — контроль расходов на крафт
- Seasons — крафт требует активного сезона
- Live Feed — событие
ITEM_CRAFTEDв публичной ленте