Cases System
1. Summary
Goal: Механизм конвертации внутренней валюты (Scrap) в предметы для крафта. Игрок открывает кейсы, собирает компоненты, крафтит реальный скин и выводит в Steam.
User Value: Возможность получить реальный скин без денежных вложений, только за активность в приложении. Путь: Активность → Scrap → Кейсы → Предметы → Крафт → Вывод в Steam.
2. Business Logic
Types of Cases
- Daily Free
- Paid (Scrap)
- Streak
- Free (Coupon)
Доступ: Бесплатный
Cooldown: Регулируется CaseType.cooldownHours (по умолчанию 24ч)
Tracking: Таймер привязан к User.lastDailyCase
Цель: Retention — причина заходить каждый день
Доступ: За валюту Scrap
Cooldown: Обычно без ограничений, пока есть баланс
Цель: Sink — основной drain внутренней экономики
Доступ: За Streak Points (валюта лояльности)
Mechanic: Поощряет удержание серии ежедневных входов
Цель: Retention + награда за лояльность
Любой платный кейс может быть открыт бесплатно при наличии купона (UserFreeCaseOpens)
Приоритет: Система сначала проверяет купон, потом баланс
Источник купонов: Награда за сложные квесты
Opening Mechanics
Алгоритм открытия (реализован в CaseOpeningService):
1. Validation
- Кейс должен быть активен (
isActive: true) - Проверка cooldown:
now > lastDailyCase + cooldownHours
2. Payment Strategy
- Проверка купона (
user_free_case_opens) → если есть, списывается купон - Списание валюты (Scrap/SP) → только если нет купона
- Всё в одной atomic transaction
Система определяет daily кейсы через caseType.isDailyFree флаг. При isDailyFree = true:
- Валидация в админке гарантирует
priceScrap = 0 cooldownHoursавтоматически берётся изCaseType- При открытии:
actualScrapPrice = 0независимо от поляpriceScrap(защита)
Техническая реализация
// CaseOpeningService.openCase()
const isDailyFree = caseData.caseType.isDailyFree;
const actualScrapPrice = hasFreeOpens || isDailyFree || isStreakPointsCase ? 0 : caseData.priceScrap;
Логика гарантирует что daily кейсы всегда бесплатны, даже если в БД ошибочно указана цена.
3. RNG & Boost
Weighted Random выборка: P(item) = weight / sum(weights)
Luck Pool увеличивает шансы FRAGMENT/BLUEPRINT для активных игроков (× 3-13).
4. Reward Granting
- Предмет создаётся в инвентаре
- Валютные награды (Scrap/XP) начисляются сразу на счёт
5. Analytics
- Запись в
CaseOpeningс snapshot выпавшей награды
Edge Cases
Что видит пользователь (UI):
| Ситуация | UI поведение |
|---|---|
| ❌ Баланс < цены | Кнопка disabled, tooltip "Недостаточно скрапа" |
| ⏱️ Cooldown активен | Кнопка disabled, текст "Доступен через 2ч 15м" |
| 🎫 Есть купон | Кнопка "Открыть бесплатно", баланс не трогаем |
При race condition или stale cache — Telegram.WebApp.showAlert() с локализованным сообщением + haptic feedback.
Что видит админ при создании/редактировании кейса:
| Ситуация | Валидация | Error Message |
|---|---|---|
| ❌ Daily type + price ≠ 0 | Блокируется | "Daily free cases must have priceScrap = 0" |
| ✅ Daily type + price = 0 | Разрешено | Auto-set cooldownHours из CaseType если не указан |
| ❌ Изменить цену daily кейса на ≠ 0 | Блокируется | Валидация проверяет и текущий тип |
Валидация в AdminCaseService предотвращает создание некорректных daily кейсов, даже если админ попытается обойти UI. Defense in depth.
Backend Error Codes (для API/тестов)
| Код | HTTP | Сообщение пользователю |
|---|---|---|
INSUFFICIENT_BALANCE | 400 | "Недостаточно Scrap для открытия кейса" |
INSUFFICIENT_STREAK_POINTS | 400 | "Недостаточно Streak Points для открытия кейса" |
COOLDOWN_ACTIVE | 400 | "Case is on cooldown. Try again in X minutes" |
CASE_NOT_FOUND | 404 | "Кейс не найден" |
Это defense in depth — фронт блокирует UI, бекенд защищает данные.
3. ADR (Architectural Decisions)
Почему Weighted Random, а не фиксированные шансы?
Проблема: Нужна гибкая настройка вероятностей без изменения кода.
Решение: Weighted Random с весами в БД (CaseReward.weight).
Альтернативы (отклонены):
- Фиксированные проценты — сложнее балансировать при добавлении предметов
- Pity system (гарантированные дропы) — усложняет логику, убивает чистый азарт
Последствия: Простота настройки, но требует внимательного баланса.
Почему атомарные транзакции?
При открытии кейса нужно одновременно списать баланс И выдать награду. Если падает между операциями — inconsistency данных.
Решение: Prisma $transaction для atomic operations.
Последствия: Гарантированная консистентность, но блокировка записей на время транзакции.
Что именно блокируется?
PostgreSQL использует row-level locking — блокируются только строки, участвующие в транзакции (запись конкретного пользователя), а не вся БД.
| Параллельная операция | Блокируется? |
|---|---|
| Другой юзер открывает кейс | Нет |
| Этот же юзер открывает второй кейс | Да, ждёт |
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| CaseOpeningService | backend/src/domains/cases/services/case-opening.service.ts | Оркестратор открытия |
| CaseRepository | backend/src/domains/cases/repositories/case.repository.ts | Сложные выборки |
| Routes | backend/src/domains/cases/routes/case.routes.ts | User API |
| Admin Routes | backend/src/domains/cases/routes/admin-case.routes.ts | Admin API |
| Schemas | backend/src/domains/cases/schemas/case.schemas.ts | Валидация |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| Case | Конфигурация кейса | price, currencyType, caseTypeId, isActive |
| CaseType | Группировка кейсов | name, cooldownHours, isFree |
| CaseReward | Связь кейса с наградами | caseId, rewardId, weight |
| UserFreeCaseOpens | Купоны пользователя | userId, caseId, count |
| CaseOpening | История открытий | userId, caseId, rewardSnapshot, createdAt |
Relationships
6. API Endpoints
- User API
- Admin: Management
- Admin: Rewards
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /api/cases | Список активных кейсов | → |
| GET | /api/cases/:id | Детали кейса | → |
| POST | /api/cases/:id/open | Открытие (RNG) | → |