Skip to main content

Craft System

1. Summary

Goal: Система крафта скинов из материалов (FRAGMENT, BLUEPRINT, RESOURCE). Финальный шаг геймификации: собранные из кейсов компоненты конвертируются в реальный скин для вывода в Steam.

User Value: Возможность собрать желаемый скин, а не надеяться на RNG. Путь: Кейсы → Материалы → Крафт → Скин → Вывод в Steam.


2. Business Logic

Craft Materials

Тип: Осколок скина

Получение: Выпадает из кейсов (с буст-шансом в Luck Pool)

Tier: Наследуется от targetSkin

Цель: Основной материал для крафта

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

Порядок проверки
  1. Достаточно Scrap (user.scrap >= craftScrapCost)
  2. Все материалы в инвентаре (сумма по всем 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 LimitAuthValidation
Check craftgeneral (100/min)TelegramCheckCraftAbilitySchema
Craft itemmutations (15/min)Telegram + Active SeasonCraftItemSchema
Get craftablegeneral (100/min)TelegramGetCraftableItemsSchema
Skin progress summaryadminApiLimiterAdmin JWTGetSkinProgressSummarySchema
Skin progress usersadminApiLimiterAdmin JWTGetSkinProgressUsersSchema
Get drop overridesadminApiLimiterAdmin JWTGetDropOverridesSchema
Upsert drop overrideadminApiLimiterAdmin JWTUpsertDropOverrideSchema
Delete drop overrideadminApiLimiterAdmin JWTDeleteDropOverrideSchema
Детали реализации

См. 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_SCRAP400"Недостаточно Scrap для крафта"
MISSING_MATERIALS400"Недостаточно материалов для крафта"
ITEM_NOT_FOUND404"Предмет не найден"
NOT_CRAFTABLE400"Предмет нельзя скрафтить"
INVALID_RECIPE400"Некорректный рецепт крафта"
ITEM_NOT_ACTIVE400"Предмет неактивен"
USER_NOT_FOUND404"Пользователь не найден"
ITEM_NOT_SKIN400"Крафтить можно только скины"
INVALID_CRAFT_COST400"Некорректная стоимость крафта"

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 юзеров):

  1. item.findMany — все craftable SKINs
  2. userInventory.groupBy([userId, itemId], _sum: quantity) — материалы всех юзеров
  3. user.findMany(select: {id, scrap}) — scrap балансы
  4. 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

КомпонентПутьОписание
CraftServicebackend/src/domains/craft/services/craft.service.tsОсновная бизнес-логика
CraftControllerbackend/src/domains/craft/controllers/user-craft.controller.tsHTTP handlers
Routesbackend/src/domains/craft/routes/user-crafts.routes.tsUser API endpoints
Schemasbackend/src/domains/craft/schemas/user-crafts.schemas.tsВалидация запросов/ответов
Typesbackend/src/domains/craft/types/craft.types.tsTypeScript типы и error codes
SkinProgressServicebackend/src/domains/craft/services/skin-progress.service.tsAdmin-аналитика прогресса крафта
AdminCraftControllerbackend/src/domains/craft/controllers/admin-craft.controller.tsAdmin HTTP handlers
Admin Routesbackend/src/domains/craft/routes/admin-craft.routes.tsAdmin API endpoints
AdminCraftSchemasbackend/src/domains/craft/schemas/admin-craft.schemas.tsAdmin валидация (GetSkinProgressSummarySchema, GetSkinProgressUsersSchema)
AdminDropOverrideServicebackend/src/domains/craft/services/admin-drop-override.service.tsCRUD оверрайдов + getActiveOverridesForUser для drop flow
AdminDropOverrideSchemasbackend/src/domains/craft/schemas/admin-drop-override.schemas.tsВалидация drop override endpoints
DropOverrideTypesbackend/src/domains/craft/types/drop-override.types.tsAdminDropOverrideDTO, DropOverrideMap

5. Database Schema

Models

МодельОписаниеКлючевые поля
ItemПредмет (скин или материал)craftRecipe, craftScrapCost, itemType, tier
CraftHistoryИстория крафтаuserId, itemId, spentScrap, materialsUsed
CraftBudgetLogЛог для Budget ControlskinId, costRub, hadBoost, totalBoostMultiplier
AdminDropOverrideАдминский оверрайд шанса дропаuserId, skinId, seasonId, multiplier, isActive

Relationships

Структура AdminDropOverride
ПолеТипОписание
idStringCUID
userIdStringПользователь, на которого действует оверрайд
skinIdStringСкин, материалы которого затронуты
seasonIdStringСезон (оверрайд привязан к сезону)
multiplierFloatМножитель веса: 0.2510.0. Значение 1.0 = нет эффекта
reasonString?Опциональная причина (для аудита между админами)
isActiveBooleanМожно деактивировать без удаления

Unique constraint: @@unique([userId, skinId, seasonId])

Index: @@index([userId, seasonId, isActive])

Структура CraftHistory
ПолеТипОписание
idStringCUID
userIdStringКто скрафтил
itemIdStringКакой скин получен
spentScrapIntПотрачено Scrap
materialsUsedJSON{ itemId: quantity }
gainedXPIntXP за крафт (сейчас 0)
craftedAtDateTimeКогда
Структура CraftBudgetLog
ПолеТипОписание
idStringCUID
userIdStringКто скрафтил
skinIdStringID скина
periodIdStringID бюджетного периода
tierItemTierРедкость скина
costRubFloatСтоимость в рублях
activePeriodsIntСтаж в пуле на момент крафта
hadBoostBooleanБыл ли буст
seniorityMultiplierFloatМножитель стажа (1.0-4.3)
totalBoostMultiplierFloatИтоговый буст (3.0-12.9)
blockedSkinIdsString[]Скины ≥50% на момент крафта
craftedAtDateTimeКогда

6. API Endpoints

МетодЭндпоинтОписаниеDocs
GET/api/craft/check/:itemIdПроверка возможности крафта
POST/api/craft/:itemIdВыполнить крафт
GET/api/craft/availableСписок всех крафтовых предметов

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
  • 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.


  • Cases — источник материалов (FRAGMENT, BLUEPRINT, RESOURCE)
  • Inventory — хранение материалов и скрафченных скинов
  • Luck Pool — буст вероятности материалов для активных игроков
  • Budget — контроль расходов на крафт
  • Seasons — крафт требует активного сезона
  • Live Feed — событие ITEM_CRAFTED в публичной ленте