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: Cooldown привязан к CaseOpening.openedAt (per-case, не глобальный)
Цель: Retention — причина заходить каждый день
Доступ: За валюту Scrap
Cooldown: Обычно без ограничений, пока есть баланс
Цель: Sink — основной drain внутренней экономики
Доступ: За Streak Points (валюта лояльности)
Mechanic: Поощряет удержание серии ежедневных входов
Цель: Retention + награда за лояльность
Любой платный кейс может быть открыт бесплатно при наличии купона (UserFreeCaseOpens)
Приоритет: Система сначала проверяет купон, потом баланс
Источник купонов: Награда за сложные квесты
Opening Mechanics
Алгоритм открытия (реализован в CaseOpeningService):
1. Validation
- Кейс должен быть активен (
isActive: true) - Проверка cooldown: последнее открытие этого кейса (
CaseOpening.openedAt) +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 Algorithm — полное описание
Принцип: Один бросок по всему пулу наград (НЕ двухэтапная фильтрация "тип → предмет").
Формула: P(item) = weight / sum(weights)
Алгоритм (selectRewardByWeight):
- Суммируем все веса наград кейса →
totalWeight - Генерируем
random = Math.random() * totalWeight(диапазон[0, totalWeight)) - Итерируем награды, вычитая вес:
random -= item.weight - Когда
random <= 0→ награда выбрана
Визуально (пример кейса с totalWeight = 1957):
|--- SCRAP 500 ---|--- XP 500 ---|--- Shield 300 ---|--- Boost 200 ---|...мелкие...|
0 500 1000 1300 1500 1957
Одно случайное число → одна точка на линейке → одна награда.
Гарантии:
- Порядок наград в массиве не влияет на вероятности (математически доказуемо)
Math.random()в V8 — xorshift128+, период 2^128- Верифицировано симуляцией 100,000 запусков: отклонение < 0.02%
Реализация: backend/src/domains/cases/utils/weighted-random.ts
Модификаторы весов (применяются до броска):
| Условие | Эффект | Затронуты | Не затронуты |
|---|---|---|---|
| Luck Pool активен | × 3.0–12.9 к весу | FRAGMENT/BLUEPRINT с targetSkinId ∈ boostedSkinIds | SCRAP, XP, BUFF, RESOURCE, SKIN |
| Budget исчерпан | × 0.01 к весу | FRAGMENT/BLUEPRINT с targetSkinId | SCRAP, XP, BUFF, RESOURCE, SKIN |
| Ничего | Без изменений | — | — |
При исчерпанном бюджете FRAGMENT/BLUEPRINT фактически исключаются из пула (× 0.01). Это увеличивает относительную долю BUFF/RESOURCE предметов. Streak Shield (BUFF, weight 300) может стать ~40% всех ITEM дропов вместо ~33%.
isBudgetExhausted() возвращает true если:
Season.isManualBudgetLock = true(ручная блокировка), ИЛИ- Нет активного
BudgetPeriod(!period), ИЛИ available = baseBudgetRub + carriedOverRub - spentRub <= 0
Фронтенд-синхронизация: Бэкенд выбирает победителя → возвращает wonReward в API → фронтенд ставит его на WIN_INDEX = 48 в рилсе до начала анимации. Визуальная анимация — чисто CSS, не влияет на результат.
4. Reward Granting
- Предмет создаётся в инвентаре
- Валютные награды (Scrap/XP) начисляются сразу на счёт
5. Analytics
- Запись в
CaseOpeningс snapshot выпавшей награды
Admin Analytics
Аналитика реальных открытий кейсов — сравнение текущих весов с фактической частотой выпадения.
Режимы:
| Режим | Endpoint | Expected/Deviation | hasSpendData |
|---|---|---|---|
| Per-case | GET /admin/cases/:id/analytics | Показывается (на основе текущих CaseReward.weight) | true |
| Global | GET /admin/cases/analytics | Скрыто (null) — нет единого набора весов | true |
Ключевые особенности:
- Период: По умолчанию 30 дней. Фильтры: 7/30/90 дней + произвольный диапазон
- Summary cards: Всего открытий, уникальных пользователей, потрачено Scrap, бесплатных открытий
- Zero-fill: Награды с 0 дропов за период показываются с
actualCount=0и отрицательнымdeviation— помогает обнаружить награды, которые перестали выпадать - Удалённые награды: Если награда была в истории, но удалена из текущей конфигурации — показывается с
expectedPercent=0(исторические данные сохраняются) - Агрегация: JS-функция
aggregateOpenings()изutils/analytics.ts— чистая функция без побочных эффектов, переиспользуется в 4 сервисных методах
Tooltip на колонке "Ожид.": "Текущий настроенный вес. Если веса менялись в выбранном периоде, отклонение может быть неточным". При < 50 открытиях отклонения в 5-10% — статистически нормальны.
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 | Сообщение пользователю |
|---|---|---|
CASE_NOT_FOUND | 404 | "Кейс не найден" |
CASE_NOT_ACTIVE | 400 | "Кейс неактивен" |
USER_NOT_FOUND | 404 | "Пользователь не найден" |
COOLDOWN_ACTIVE | 400 | "Кейс на cooldown" |
INSUFFICIENT_SCRAP | 400 | "Недостаточно Scrap" |
INSUFFICIENT_STREAK_POINTS | 400 | "Недостаточно Streak Points" |
NO_REWARDS | 400 | "Кейс не содержит наград" |
ITEM_NOT_FOUND | 404 | "Предмет не найден" |
FREE_OPENS_EXHAUSTED | 400 | "Бесплатные открытия исчерпаны" |
DEDUCT_STREAK_POINTS_FAILED | 500 | "Ошибка списания Streak Points" |
UNKNOWN_REWARD_TYPE | 500 | "Неизвестный тип награды" |
Это defense in depth — фронт блокирует UI, бекенд защищает данные.
Diagnostics: Drop Distribution
При жалобах пользователей на повторяющиеся дропы используй следующий чеклист.
Observability лог ([Case] Reward selected) записывается при каждом открытии:
{
"msg": "[Case] Reward selected",
"userId": "xxx",
"caseId": "yyy",
"rewardName": "Streak Shield",
"rewardType": "ITEM",
"rewardItemId": "STREAK_SHIELD",
"selectedWeight": 300,
"totalWeight": 1957,
"rewardsCount": 14,
"dropChancePct": 15.33,
"budgetExhausted": false,
"boostActive": false
}
Чеклист диагностики:
| # | Что проверить | Поле в логе | Красный флаг |
|---|---|---|---|
| 1 | Все ли награды загрузились? | rewardsCount | Ожидается 8–15. Если 1–3 → баг кеша Redis |
| 2 | Правильный ли суммарный вес? | totalWeight | Сравнить с SELECT SUM(weight) FROM case_rewards WHERE caseId = ? |
| 3 | Бюджет не исчерпан ошибочно? | budgetExhausted | true при нормальном бюджете → проверить BudgetPeriod.isActive |
| 4 | Буст не применяется лишний раз? | boostActive | true у пользователя, которого нет в Luck Pool |
| 5 | Реальный % совпадает с ожидаемым? | dropChancePct | Отклонение > 5% от CaseReward.displayDropChance |
SQL-запросы для расследования:
-- Частота дропов конкретного предмета для пользователя
SELECT (co."rewardSnapshot"::json->>'itemName') as item,
COUNT(*) as times, COUNT(*)::float / (SELECT COUNT(*) FROM case_openings WHERE "userId" = 'XXX') * 100 as pct
FROM case_openings co WHERE co."userId" = 'XXX' AND co."rewardType" = 'ITEM'
GROUP BY 1 ORDER BY times DESC;
-- Сравнение ожидаемого vs фактического для кейса
SELECT r.name, cr.weight,
ROUND(cr.weight::numeric / SUM(cr.weight) OVER() * 100, 2) as expected_pct,
(SELECT COUNT(*) FROM case_openings co
WHERE co."caseId" = cr."caseId" AND co."rewardSnapshot"::text LIKE '%' || r."itemId" || '%') as actual_drops
FROM case_rewards cr JOIN rewards r ON r.id = cr."rewardId"
WHERE cr."caseId" = 'YYY' ORDER BY cr.weight DESC;
При малых выборках (< 50 открытий) отклонения в 2–3× от ожидаемого — нормальны. Для значимых выводов нужно > 100 открытий конкретного кейса. Используй биномиальный тест: P(X >= k) = Σ C(n,k) × p^k × (1-p)^(n-k).
3. ADR (Architectural Decisions)
Почему Weighted Random, а не фиксированные шансы?
Проблема: Нужна гибкая настройка вероятностей без изменения кода.
Решение: Weighted Random с весами в БД (CaseReward.weight).
Альтернативы (отклонены):
- Фиксированные проценты — сложнее балансировать при добавлении предметов
- Pity system (гарантированные дропы) — усложняет логику, убивает чистый азарт
Последствия: Простота настройки, но требует внимательного баланса.
Почему JS-агрегация для аналитики, а не SQL?
Проблема: Аналитика открытий кейсов требует агрегации по rewardSnapshot (JSON поле). Нужно группировать по reward ID из JSON, считать проценты, отклонения, summary.
Решение: Чистая JS-функция aggregateOpenings() — получаем записи из БД, итерируем в JS.
Альтернативы (отклонены):
- SQL
GROUP BYсrewardSnapshot::json->>'id'— работает, но сложнее поддерживать, нет type safety, хуже тестируемость - Материализованные views — overhead для 50к записей/месяц
Последствия: 50к записей/месяц — PostgreSQL fetch + JS итерация < 50ms. При росте до 500к+ стоит рассмотреть SQL-агрегацию.
Почему отдельный тип AnalyticsResponse (а не SimulationResponse)?
Проблема: Аналитика и симуляция показывают похожие таблицы, но имеют разную структуру данных.
Решение: Отдельный тип AnalyticsResponse с nullable expectedPercent/deviation и блоком summary.
Причины:
summary(uniqueUsers, totalScrapSpent, freeOpenings, hasSpendData) — нет в симуляцииexpectedPercentиdeviation—number | null(null для глобальной аналитики, всегда number в симуляции)dateFrom/dateToвместоopenings
Последствия: Структура results[] и totals идентична SimulationResponse — фронтенд переиспользует RewardResultsTable компонент.
Почему атомарные транзакции?
При открытии кейса нужно одновременно списать баланс И выдать награду. Если падает между операциями — 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 | Оркестратор открытия |
| CaseService | backend/src/domains/cases/services/case.service.ts | User API: список, детали кейсов |
| AdminCaseService | backend/src/domains/cases/services/admin-case.service.ts | Admin CRUD |
| CaseRepository | backend/src/domains/cases/repositories/case.repository.ts | Сложные выборки |
| UserCaseController | backend/src/domains/cases/controllers/user-case.controller.ts | User API: список/детали |
| UserCaseOpeningController | backend/src/domains/cases/controllers/user-case-opening.controller.ts | User API: открытие |
| AdminCaseController | backend/src/domains/cases/controllers/admin-case.controller.ts | Admin API |
| CaseMapper | backend/src/domains/cases/mappers/case.mapper.ts | Entity → response mapping |
| Routes | backend/src/domains/cases/routes/user-cases.routes.ts | User API |
| Admin Routes | backend/src/domains/cases/routes/admin-cases.routes.ts | Admin API |
| Schemas | backend/src/domains/cases/schemas/case.schemas.ts | Валидация |
| Analytics Schemas | backend/src/domains/cases/schemas/analytics.schemas.ts | Swagger-схемы аналитики (shared cases+spins) |
| Types | backend/src/domains/cases/types/case.types.ts | CaseError, CaseErrorCode, типы |
| Analytics Types | backend/src/domains/cases/types/analytics.types.ts | AnalyticsResponse, AnalyticsRewardResult |
| Analytics Util | backend/src/domains/cases/utils/analytics.ts | Чистая функция агрегации aggregateOpenings() |
| RewardResultsTable | admin/src/components/cases/RewardResultsTable.tsx | Shared таблица (SimulationModal + AnalyticsModal) |
| AnalyticsModal | admin/src/components/cases/AnalyticsModal.tsx | Модалка аналитики per-case/spin |
| useAnalytics | admin/src/hooks/useAnalytics.ts | TanStack Query хуки для аналитики |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| Case | Конфигурация кейса | name, imageUrl, priceScrap, pricePoints, currencyType, cooldownHours, caseTypeId, isActive, availableFrom, availableTo, sortOrder, totalOpened |
| CaseType | Группировка кейсов | name, slug, cooldownHours, isDailyFree, sortOrder, isActive, availableFrom, availableTo |
| CaseReward | Связь кейса с наградами | caseId, rewardId, weight |
| UserFreeCaseOpens | Купоны пользователя | userId, caseId, count |
| Reward | Универсальная награда (для кейсов, квестов, достижений) | name, type, itemId, amount, amountMax, caseId |
| CaseOpening | История открытий | userId, caseId, rewardSnapshot, rewardType, rewardAmount, paidScrap, openedAt. Индексы: [caseId, openedAt] (analytics) |
| UserCaseStats | Агрегированная статистика открытий (per-user per-case, денормализация) | userId, caseId, totalOpened, totalScrapSpent, lastOpenedAt |
ItemType Enum
| Значение | Описание |
|---|---|
SKIN | Скин (основной предмет для вывода в Steam) |
BLUEPRINT | Рецепт для крафта |
FRAGMENT | Осколок (компонент для крафта) |
RESOURCE | Ресурс (ткань, металл и т.д.) |
BUFF | Временный бонус (баффы XP, Scrap, защита streak) |
Relationships
6. API Endpoints
- User API
- Admin: Management
- Admin: Rewards
- Admin: Rewards (Global)
- Admin: Items
- Admin: Case Types
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /api/cases | Список активных кейсов | → |
| GET | /api/cases/cooldowns | Таймстампы последних открытий (для cooldown) | → |
| GET | /api/cases/free-opens | Купоны бесплатных открытий | → |
| GET | /api/cases/:id | Детали кейса | → |
| POST | /api/cases/:id/open | Открытие (RNG) | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/cases | Все кейсы | → |
| POST | /admin/cases | Создание | → |
| POST | /admin/cases/bulk-action | Массовые операции | → |
| POST | /admin/cases/upload-image | Загрузка изображения кейса | → |
| GET | /admin/cases/:id | Детали | → |
| PUT | /admin/cases/:id | Редактирование | → |
| DELETE | /admin/cases/:id | Удаление | → |
| PATCH | /admin/cases/:id/toggle-active | Вкл/выкл | → |
| POST | /admin/cases/:id/simulate | Симуляция N открытий кейса (budget/boost модификаторы) | — |
| GET | /admin/cases/:id/analytics | Аналитика открытий конкретного кейса (реальные данные) | — |
| GET | /admin/cases/analytics | Глобальная аналитика по всем кейсам | — |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/cases/:id/rewards | Награды кейса | → |
| POST | /admin/cases/:id/rewards | Добавить | → |
| PATCH | /admin/cases/:id/rewards/:rewardId | Изменить вес | → |
| DELETE | /admin/cases/:id/rewards/:rewardId | Убрать | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/rewards | Все награды (с фильтрацией) | → |
| POST | /admin/rewards | Создать награду | → |
| GET | /admin/rewards/:id | Детали награды | → |
| PUT | /admin/rewards/:id | Обновить награду | → |
| DELETE | /admin/rewards/:id | Удалить награду | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/items | Все предметы (с фильтрацией) | → |
| POST | /admin/items | Создать предмет | → |
| POST | /admin/items/bulk-action | Массовые операции | → |
| POST | /admin/items/extract-steam-data | Извлечь данные со Steam Market по URL | → |
| POST | /admin/items/upload-image | Загрузка изображения предмета (multipart) | → |
| POST | /admin/items/download-image-from-url | Скачать изображение по URL (Steam импорт) | → |
| GET | /admin/items/:id | Детали предмета | → |
| GET | /admin/items/:id/related | Связанные FRAGMENT/BLUEPRINT предметы | → |
| GET | /admin/items/:id/usage | Использование предмета в кейсах | → |
| PUT | /admin/items/:id | Обновить предмет | → |
| DELETE | /admin/items/:id | Удалить предмет | → |
| PATCH | /admin/items/:id/toggle-active | Вкл/выкл предмет | → |