Daily Spins
1. Summary
Goal: Retention и engagement через систему рулеток. Бесплатный ежедневный спин — причина заходить каждый день. Платные спины — sink для внутренней валюты.
User Value: Получение наград (Scrap, XP, предметы для крафта) с гарантированным бесплатным ежедневным входом. Путь: Активность → Daily Spin → Награды → Крафт.
2. Business Logic
Types of Spins
- Daily Free
- Paid (Scrap)
- Paid (Streak Points)
- Special
Доступ: Бесплатный, один раз в сутки
Cooldown: Регулируется DailySpin.cooldownHours (по умолчанию 24ч)
Tracking: Per-spin cooldown через SpinResult.spunAt
Цель: Retention — причина заходить каждый день
Доступ: За валюту Scrap (priceScrap > 0)
Cooldown: Обычно без ограничений, пока есть баланс
Цель: Sink — drain внутренней экономики
Доступ: За Streak Points (currencyType: STREAK_POINTS)
Mechanic: Поощряет удержание серии ежедневных входов
Цель: Дополнительная ценность для лояльных игроков
Доступ: Ограничен периодом (availableFrom / availableTo на SpinType)
Mechanic: Event-рулетки на праздники, спецакции
Цель: Создание срочности и excitement
Поля availableFrom/availableTo существуют на обеих моделях — SpinType и DailySpin. Однако runtime-код проверяет период доступности по полям SpinType.
Spinning Mechanics
Алгоритм спина (реализован в UserSpinService):
1. Validation
- Спин должен быть активен (
isActive: true) - Проверка периода доступности (для Special)
- Проверка per-spin cooldown
2. Payment Strategy
- Если
priceScrap = 0иpricePoints = null→ бесплатный спин - Если
currencyType = SCRAP→ проверкаuser.scrap >= priceScrap - Если
currencyType = STREAK_POINTS→ проверкаuser.streakPoints >= pricePoints - Всё в одной atomic transaction
3. RNG & Boost
Weighted Random выборка: P(item) = weight / sum(weights)
Luck Pool увеличивает шансы FRAGMENT/BLUEPRINT для активных игроков (× 3.0-12.9).
4. Reward Distribution
- SCRAP: Начисляется на баланс + применяется
SCRAP_BUFFесли активен - XP: Начисляется + применяется
XP_BUFFесли активен - ITEM: Добавляется в инвентарь с
sourceType: DAILY_SPIN
5. Side Effects
- Запись в
SpinResultс snapshot награды - Публикация в Live Feed (если награда значительная)
- Passive Income для рефереров (если Scrap)
- Обновление Season Stats
- Обновление прогресса квестов (для каждого типа награды — свой тип прогресса)
- Обновление активности в Luck Pool (
luckPoolService.updateActivity)
Прогресс квестов обновляется для всех типов наград: ITEM → COLLECTION, SCRAP → scrap-earn, XP → xp-earn. Каждый тип награды триггерит свою категорию квестового прогресса.
Admin Analytics
Аналитика реальных вращений рулеток — сравнение текущих весов секторов с фактической частотой выпадения.
| Режим | Endpoint | Expected/Deviation | hasSpendData |
|---|---|---|---|
| Per-spin | GET /admin/daily-spins/:id/analytics | Показывается (на основе SpinItem.weight) | false |
| Global | GET /admin/daily-spins/analytics | Скрыто (null) | false |
SpinResult не хранит paidScrap (в отличие от CaseOpening). Поэтому summary.totalScrapSpent = 0, summary.freeOpenings = 0, summary.hasSpendData = false. Фронтенд скрывает карточки "Потрачено" и "Бесплатных" для спинов.
Особенности: Zero-fill, удалённые награды, period filters — аналогично Cases Analytics.
Edge Cases
Что видит пользователь (UI):
| Ситуация | UI поведение |
|---|---|
| ❌ Баланс < цены | Кнопка disabled, показ недостающей суммы |
| ⏱️ Cooldown активен | Таймер обратного отсчёта |
| 📅 Вне периода | Спин скрыт или показана дата начала |
| 🎰 Несколько спинов | Карусель/список активных спинов |
Daily spin использует deferred balance update — баланс обновляется не при ответе API, а при нажатии "Забрать" (чтобы баланс не прыгал во время анимации колеса). Callback setPendingBalanceCommit обязан синхронно обновлять кэш через addScrapToCache()/addXpToCache() перед invalidateQueries(). Без этого updateLastKnownBalance() сохраняет stale баланс → при перезаходе ложная "внешняя награда". Подробнее: Data Sync → Deferred Balance Commit.
Backend Error Codes (для API/тестов)
| Код | HTTP | Сообщение |
|---|---|---|
SPIN_NOT_FOUND | 400/404 | "Рулетка не найдена" (404 в check-cooldown, 400 в spin) |
SPIN_NOT_ACTIVE | 400 | "Рулетка неактивна или недоступна в текущем сезоне" |
SPIN_NOT_AVAILABLE | 400 | "Рулетка недоступна в данный период" |
SPIN_EXPIRED | 400 | "Рулетка больше недоступна (истёк период)" |
NO_ITEMS | 400 | "Рулетка не содержит наград" |
USER_NOT_FOUND | 404 | "Пользователь не найден" |
INSUFFICIENT_SCRAP | 400 | "Недостаточно Scrap" |
INSUFFICIENT_STREAK_POINTS | 402 | "Недостаточно Streak Points" |
COOLDOWN_ACTIVE | 429 | "Спин на кулдауне. Попробуйте через X минут" |
STREAK_POINTS_DEDUCTION_FAILED | 500 | "Ошибка списания Streak Points" |
UNKNOWN_REWARD_TYPE | 500 | "Неизвестный тип награды" |
INVALID_REWARD | 500 | "Невалидная награда (отсутствует itemId)" |
3. ADR (Architectural Decisions)
Почему cooldownHours на DailySpin, а не SpinType?
Проблема: Несколько спинов могут иметь разные кулдауны. Daily = 24ч, Paid = 0ч.
Решение: Кулдаун управляется на уровне DailySpin.cooldownHours, не SpinType.
Альтернативы (отклонены):
- SpinType.cooldownHours — не поддерживает разные кулдауны для спинов одного типа
- Глобальный User.lastDailySpin — не поддерживает multi-spin
Последствия: Гибкость настройки, но требует проверки кулдауна per-spin.
Почему Per-Spin Cooldown Tracking?
Пользователь может крутить разные рулетки параллельно. Нельзя блокировать все спины одним таймером.
Решение: Кулдаун проверяется через SpinResult.spunAt для конкретной пары (userId, spinId).
14:00 — пользователь крутит Рулетка #1
14:05 — пользователь крутит Рулетка #2 ✅ (разные спины)
14:25 — пользователь пытается крутить Рулетка #1 ❌ (кулдаун)
Почему RewardSnapshot в SpinResult?
Проблема: Награды могут быть удалены/изменены, но история должна показывать что выиграл пользователь.
Решение: JSON snapshot с полными данными награды на момент спина.
Формат RewardSnapshot
{
"id": "reward-id",
"type": "SCRAP",
"name": "500 Scrap",
"amount": 500,
"itemId": null,
"itemName": null,
"itemImageUrl": null,
"itemTier": null,
"itemType": null,
"buffBonus": {
"type": "SCRAP_BUFF",
"baseAmount": 500,
"bonusAmount": 150,
"multiplier": 1.3,
"buffName": "Scrap Catalyst",
"buffTier": "COMMON"
}
}
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| UserSpinService | backend/src/domains/cases/services/user-spin.service.ts | Основная логика спина |
| AdminSpinService | backend/src/domains/cases/services/admin-spin.service.ts | CRUD + статистика |
| DailySpinRepository | backend/src/domains/cases/repositories/daily-spin.repository.ts | Data Access |
| User Routes | backend/src/domains/cases/routes/user-spin.routes.ts | User API |
| Admin Routes | backend/src/domains/cases/routes/admin-daily-spins.routes.ts | Admin API (Full CRUD) |
| Admin Results Routes | backend/src/domains/cases/routes/admin-spin-results.routes.ts | Admin: Spin Results History |
| Admin Types Routes | backend/src/domains/cases/routes/admin-spin-types.routes.ts | Admin: Spin Types |
| Schemas | backend/src/domains/cases/schemas/user-spin.schemas.ts | Валидация |
Код daily-spin находится в domains/cases вместе с кейсами — общая RNG механика.
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| SpinType | Группировка спинов | slug, isDailyFree, availableFrom, availableTo |
| DailySpin | Конфигурация рулетки | spinTypeId, priceScrap, currencyType, cooldownHours, availableFrom, availableTo |
| SpinItem | Сектор рулетки | spinId, rewardId, weight, displayDropChance |
| SpinResult | История спинов | userId, spinId, rewardSnapshot, rewardType, rewardAmount, spunAt. Индексы: [spinId, spunAt] (analytics) |
Поля rewardType (RewardType enum: ITEM, SCRAP, XP) и rewardAmount (Int, null для ITEM) добавлены для быстрой статистики без парсинга JSON rewardSnapshot.
Relationships
6. API Endpoints
- User API
- Admin: Management
- Admin: Items & Stats
- Admin: Results & Types
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /api/daily-spin/list | Все активные спины (карусель) | → |
| GET | /api/daily-spin/:spinId/check-cooldown | Проверка кулдауна и баланса | → |
| POST | /api/daily-spin/:spinId/spin | Выполнить спин | → |
| GET | /api/daily-spin/history | История спинов пользователя | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/daily-spins | Список всех спинов | → |
| POST | /admin/daily-spins | Создать спин | → |
| GET | /admin/daily-spins/:id | Детали спина | → |
| PUT | /admin/daily-spins/:id | Обновить спин | → |
| PUT | /admin/daily-spins/:id/activate | Активировать | → |
| DELETE | /admin/daily-spins/:id | Удалить спин | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/daily-spins/:id/items | Секторы спина | → |
| POST | /admin/daily-spins/:id/items | Добавить сектор | → |
| PATCH | /admin/daily-spins/items/:itemId | Изменить вес сектора | → |
| DELETE | /admin/daily-spins/items/:itemId | Убрать сектор | → |
| GET | /admin/daily-spins/stats | Статистика спинов | — |
| POST | /admin/daily-spins/:id/simulate | Симуляция N вращений (без реальных транзакций) | — |
| GET | /admin/daily-spins/:id/analytics | Аналитика вращений конкретного спина (реальные данные) | — |
| GET | /admin/daily-spins/analytics | Глобальная аналитика по всем спинам | — |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/spin-results | История спинов (результаты) | — |
| GET | /admin/spin-types | Список типов спинов | — |
| PATCH | /admin/spin-types/:id | Обновить тип спина | — |