Skip to main content

Cases System

1. Summary

Goal: Механизм конвертации внутренней валюты (Scrap) в предметы для крафта. Игрок открывает кейсы, собирает компоненты, крафтит реальный скин и выводит в Steam.

User Value: Возможность получить реальный скин без денежных вложений, только за активность в приложении. Путь: Активность → Scrap → Кейсы → Предметы → Крафт → Вывод в Steam.


2. Business Logic

Types of Cases

Доступ: Бесплатный

Cooldown: Регулируется CaseType.cooldownHours (по умолчанию 24ч)

Tracking: Cooldown привязан к CaseOpening.openedAt (per-case, не глобальный)

Цель: Retention — причина заходить каждый день

Opening Mechanics

Алгоритм открытия (реализован в CaseOpeningService):

1. Validation

  • Кейс должен быть активен (isActive: true)
  • Проверка cooldown: последнее открытие этого кейса (CaseOpening.openedAt) + cooldownHours

2. Payment Strategy

Порядок списания
  1. Проверка купона (user_free_case_opens) → если есть, списывается купон
  2. Списание валюты (Scrap/SP) → только если нет купона
  3. Всё в одной atomic transaction
Daily Case Detection

Система определяет 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):

  1. Суммируем все веса наград кейса → totalWeight
  2. Генерируем random = Math.random() * totalWeight (диапазон [0, totalWeight))
  3. Итерируем награды, вычитая вес: random -= item.weight
  4. Когда 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 с targetSkinIdboostedSkinIdsSCRAP, XP, BUFF, RESOURCE, SKIN
Budget исчерпан× 0.01 к весуFRAGMENT/BLUEPRINT с targetSkinIdSCRAP, XP, BUFF, RESOURCE, SKIN
НичегоБез изменений
Budget Exhaustion и разнообразие дропов

При исчерпанном бюджете FRAGMENT/BLUEPRINT фактически исключаются из пула (× 0.01). Это увеличивает относительную долю BUFF/RESOURCE предметов. Streak Shield (BUFF, weight 300) может стать ~40% всех ITEM дропов вместо ~33%.

isBudgetExhausted() возвращает true если:

  1. Season.isManualBudgetLock = true (ручная блокировка), ИЛИ
  2. Нет активного BudgetPeriod (!period), ИЛИ
  3. available = baseBudgetRub + carriedOverRub - spentRub <= 0

Фронтенд-синхронизация: Бэкенд выбирает победителя → возвращает wonReward в API → фронтенд ставит его на WIN_INDEX = 48 в рилсе до начала анимации. Визуальная анимация — чисто CSS, не влияет на результат.

4. Reward Granting

  • Предмет создаётся в инвентаре
  • Валютные награды (Scrap/XP) начисляются сразу на счёт

5. Analytics

  • Запись в CaseOpening с snapshot выпавшей награды

Admin Analytics

Аналитика реальных открытий кейсов — сравнение текущих весов с фактической частотой выпадения.

Режимы:

РежимEndpointExpected/DeviationhasSpendData
Per-caseGET /admin/cases/:id/analyticsПоказывается (на основе текущих CaseReward.weight)true
GlobalGET /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_FOUND404"Кейс не найден"
CASE_NOT_ACTIVE400"Кейс неактивен"
USER_NOT_FOUND404"Пользователь не найден"
COOLDOWN_ACTIVE400"Кейс на cooldown"
INSUFFICIENT_SCRAP400"Недостаточно Scrap"
INSUFFICIENT_STREAK_POINTS400"Недостаточно Streak Points"
NO_REWARDS400"Кейс не содержит наград"
ITEM_NOT_FOUND404"Предмет не найден"
FREE_OPENS_EXHAUSTED400"Бесплатные открытия исчерпаны"
DEDUCT_STREAK_POINTS_FAILED500"Ошибка списания Streak Points"
UNKNOWN_REWARD_TYPE500"Неизвестный тип награды"

Это 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Бюджет не исчерпан ошибочно?budgetExhaustedtrue при нормальном бюджете → проверить BudgetPeriod.isActive
4Буст не применяется лишний раз?boostActivetrue у пользователя, которого нет в 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.

Причины:

  1. summary (uniqueUsers, totalScrapSpent, freeOpenings, hasSpendData) — нет в симуляции
  2. expectedPercent и deviationnumber | null (null для глобальной аналитики, всегда number в симуляции)
  3. dateFrom/dateTo вместо openings

Последствия: Структура results[] и totals идентична SimulationResponse — фронтенд переиспользует RewardResultsTable компонент.

Почему атомарные транзакции?

Критично для консистентности

При открытии кейса нужно одновременно списать баланс И выдать награду. Если падает между операциями — inconsistency данных.

Решение: Prisma $transaction для atomic operations.

Последствия: Гарантированная консистентность, но блокировка записей на время транзакции.

Что именно блокируется?

PostgreSQL использует row-level locking — блокируются только строки, участвующие в транзакции (запись конкретного пользователя), а не вся БД.

Параллельная операцияБлокируется?
Другой юзер открывает кейсНет
Этот же юзер открывает второй кейсДа, ждёт

4. Architecture

Services Overview

Key Components

КомпонентПутьОписание
CaseOpeningServicebackend/src/domains/cases/services/case-opening.service.tsОркестратор открытия
CaseServicebackend/src/domains/cases/services/case.service.tsUser API: список, детали кейсов
AdminCaseServicebackend/src/domains/cases/services/admin-case.service.tsAdmin CRUD
CaseRepositorybackend/src/domains/cases/repositories/case.repository.tsСложные выборки
UserCaseControllerbackend/src/domains/cases/controllers/user-case.controller.tsUser API: список/детали
UserCaseOpeningControllerbackend/src/domains/cases/controllers/user-case-opening.controller.tsUser API: открытие
AdminCaseControllerbackend/src/domains/cases/controllers/admin-case.controller.tsAdmin API
CaseMapperbackend/src/domains/cases/mappers/case.mapper.tsEntity → response mapping
Routesbackend/src/domains/cases/routes/user-cases.routes.tsUser API
Admin Routesbackend/src/domains/cases/routes/admin-cases.routes.tsAdmin API
Schemasbackend/src/domains/cases/schemas/case.schemas.tsВалидация
Analytics Schemasbackend/src/domains/cases/schemas/analytics.schemas.tsSwagger-схемы аналитики (shared cases+spins)
Typesbackend/src/domains/cases/types/case.types.tsCaseError, CaseErrorCode, типы
Analytics Typesbackend/src/domains/cases/types/analytics.types.tsAnalyticsResponse, AnalyticsRewardResult
Analytics Utilbackend/src/domains/cases/utils/analytics.tsЧистая функция агрегации aggregateOpenings()
RewardResultsTableadmin/src/components/cases/RewardResultsTable.tsxShared таблица (SimulationModal + AnalyticsModal)
AnalyticsModaladmin/src/components/cases/AnalyticsModal.tsxМодалка аналитики per-case/spin
useAnalyticsadmin/src/hooks/useAnalytics.tsTanStack 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

МетодЭндпоинтОписаниеDocs
GET/api/casesСписок активных кейсов
GET/api/cases/cooldownsТаймстампы последних открытий (для cooldown)
GET/api/cases/free-opensКупоны бесплатных открытий
GET/api/cases/:idДетали кейса
POST/api/cases/:id/openОткрытие (RNG)

  • Daily Spins — похожая механика ежедневных наград
  • Inventory — куда попадают выигранные предметы
  • Luck Pool — буст вероятностей для активных игроков
  • Budget — контроль выдачи ценных наград
  • Buffs — баффы удачи влияют на RNG
  • Streaks — Streak Cases открываются за Streak Points