Streaks & Raffle System
1. Summary
Goal: Механизм лояльности через ежедневные серии входов и еженедельные розыгрыши призов. Игрок заходит каждый день, накапливает Streak Points и тратит их на билеты в розыгрышах реальных скинов.
User Value: Дополнительная мотивация заходить ежедневно + реальный шанс выиграть скин без денежных вложений, только за регулярную активность. Путь: Ежедневный вход → Streak Points → Билеты → Розыгрыш → Скин в инвентарь.
2. Business Logic
Types of Streak Rewards
- Daily Claim
- Streak Multipliers
- Raffle Tickets
- Streak Shield
- Streak Content
Доступ: Бесплатный, раз в день
Награда: Streak Points = BASE × multiplier + topBonus
Формула:
- BASE = 50 SP
- multiplier: ×1.0 → ×2.5 в зависимости от длины стрика
- topBonus: +100 (1 место), +50 (2-3 место), +25 (4-10 место)
Цель: Retention — причина заходить каждый день
Множитель растёт с длиной стрика:
| Дни подряд | Множитель |
|---|---|
| 1-6 | ×1.0 |
| 7-13 | ×1.2 |
| 14-27 | ×1.5 |
| 28-55 | ×2.0 |
| 56+ | ×2.5 (max) |
Пример: День 30 = 50 × 2.0 = 100 SP
Доступ: За Streak Points
Цена билетов (прогрессивная):
| Билет # | Цена |
|---|---|
| 1-5 | 100 SP |
| 6-15 | 150 SP |
| 16-30 | 200 SP |
| 31-50 | 300 SP |
Цель: Один из 3 sink'ов SP (вместе со Spin и Case)
Что это: Защита стрика от сброса при пропуске дня
Механика: При пропуске дня система автоматически использует shield (1 shield = 1 день, max 3 активных)
Цель: Страховка для активных игроков с длинными стриками
→ Подробнее: Buffs System
Streak Spin Wheel / Streak Case:
Админ создаёт спин или кейс с currencyType: STREAK_POINTS и задаёт pricePoints.
Обычно активен один Streak Spin и один Streak Case одновременно.
Как работает:
- Цена берётся из БД (
DailySpin.pricePoints,Case.pricePoints) - Существующие endpoints автоматически определяют валюту по
currencyType - Нет отдельных routes — те же
/api/daily-spin/spinи/api/cases/:id/open
Цель: Sink для SP — пользователь выбирает между Spin, Case или Raffle
Core Mechanics
1. Streak Management (Login-Based)
Счётчик dailyLoginStreak управляется только через StreakService.checkAndUpdateStreak() при входе пользователя (основан на lastLoginDate).
Это предотвращает двойной инкремент и гарантирует консистентность стрика.
Логика при входе:
- Если первый вход вообще →
dailyLoginStreak = 1 - Если вход был вчера →
dailyLoginStreak + 1 - Если вход был сегодня → ничего не делать
- Если пропущены дни → проверить Streak Shield, иначе reset на 1
2. Best Streak Tracking (Season & All-Time)
bestDailyLoginStreak хранится на двух уровнях:
- User — глобальный рекорд (сбрасывается каждый сезон)
- UserSeasonStats — рекорд конкретного сезона (хранится навсегда)
All-Time рекорд = MAX(USS.bestDailyLoginStreak) по всем сезонам.
Как обновляется:
При каждом инкременте стрика (daysDiff ≥ 1) вызывается updateSeasonBestStreak(userId, newStreak):
upsertUSS — гарантирует существование записиupdateManyс условиемbestDailyLoginStreak < newStreak— обновляет только если новый стрик выше текущего рекорда
Login (daysDiff=1) → streak 3→4
├── User.bestDailyLoginStreak = MAX(current, 4)
└── USS.bestDailyLoginStreak = MAX(current, 4) // conditional updateMany
Когда НЕ обновляется:
daysDiff === 0(повторный вход в тот же день) — рекорд не пересчитывается- Сброс стрика — рекорд сохраняется (записывается только при росте)
Lifecycle рекорда через сезоны
| Событие | User.bestDailyLoginStreak | USS.bestDailyLoginStreak | All-Time |
|---|---|---|---|
| Сезон 1: стрик достиг 15 | 15 | 15 (Season 1 USS) | 15 |
| Сезон 1: стрик сбросился до 1 | 15 | 15 (не меняется) | 15 |
| Season Reset | 0 (обнуляется) | 15 (фиксируется навсегда) | 15 |
| Сезон 2: стрик достиг 8 | 8 | 8 (Season 2 USS) | MAX(15, 8) = 15 |
Баланс SP отдаётся только через Profile API (GET /api/users/profile → streakPoints).
Streak Stats API (GET /api/streaks/stats) не возвращает streakPoints — только streak/shields/multiplier.
Фронтенд читает SP через profile.streakPoints (из React Query cache ['profile']).
3. Daily Claim Flow (Reward-Based)
Claim reward проверяет lastStreakPointsClaim (а не lastLoginDate) и не изменяет dailyLoginStreak.
Reward claiming и streak tracking — независимые системы с разными cooldown'ами.
Логика при claim:
- Проверка
lastStreakPointsClaim— забирал ли награду сегодня - Если нет → вызвать
checkAndUpdateStreak()для актуализации стрика - Получить текущий
dailyLoginStreakдля расчёта награды - Начисление SP = BASE × multiplier + topBonus
- Обновление
lastStreakPointsClaimавтоматически при credit()
4. Raffle Ticket System
Параметры розыгрыша зависят от Item.tier скина-приза. Tier определяется ценой скина (TIER_1 = дешёвый, TIER_5 = дорогой).
| Tier | Билетов всего | Min участников | User Limit |
|---|---|---|---|
| TIER_1 | 50 | 2 | 20% (10) |
| TIER_2 | 100 | 2 | 20% (20) |
| TIER_3 | 150 | 3 | 20% (30) |
| TIER_4 | 200 | 4 | 20% (40) |
| TIER_5 | 250 | 5 | 20% (50) |
User Limit — максимум билетов на одного участника (% от общего пула). Без лимита топ-стрикеры с большим балансом SP могли бы скупать большинство билетов, убивая конкуренцию и мотивацию для остальных.
При создании розыгрыша система копирует параметры из RAFFLE_TIERS[item.tier] в Raffle для консистентности (даже если константы изменятся, текущий розыгрыш сохранит свои правила).
5. Prize Pool Management
Пул призов — коллекция скинов с весами для взвешенного выбора следующего приза.
| Параметр | Описание |
|---|---|
weight | Вес для random (1-100, default: 50) |
isActive | Активен ли в пуле |
timesWon | Статистика — сколько раз выигран |
Алгоритм выбора:
- Получить активные предметы (
isActive: true) - Фильтровать по наличию на Steam боте (через
BotInventoryCacheService) - Weighted random:
random × totalWeight → select by threshold
Если пул пуст — админ получает Telegram уведомление, новый розыгрыш не создаётся.
6. Draw Mechanics
- Каждое воскресенье в 20:00 UTC — автоматический розыгрыш
- Если условия не выполнены → продление на 3 дня
- Максимум 1 продление → после этого отмена с рефандом
- 0 билетов → приз переносится на следующую неделю (rollover)
7. Cancel & Refund
- Админ может отменить активный розыгрыш с указанием причины
- SP автоматически возвращаются всем участникам (тип:
RAFFLE_REFUND) - Участники получают Telegram уведомление с причиной отмены
8. Decay System
- После 7 дней неактивности: -10% SP в день
- Cron job: ежедневно в 01:00 UTC
- MAX_BALANCE: 50,000 SP — лимит накопления на аккаунте
9. Season Reset
Streak Points полностью сбрасываются при смене сезона (во время COUNTDOWN). Claim и buy-ticket заблокированы в этот период.
Best Streak при reset:
Перед сбросом выполняется safety-net — фиксация рекорда через GREATEST:
UPDATE user_season_stats SET bestDailyLoginStreak = GREATEST(
uss.bestDailyLoginStreak, -- уже записанный рекорд
u.bestDailyLoginStreak, -- глобальный рекорд (на случай если синк не прошёл)
u.dailyLoginStreak -- текущий активный стрик
)
После фиксации:
User.bestDailyLoginStreak→ обнуляется (начинает копить с нового сезона)- Старая
USS.bestDailyLoginStreak→ сохраняется навсегда User.dailyLoginStreak→ не трогается (текущий стрик продолжается между сезонами)
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| Get stats | general (100/min) | Telegram | - |
| Claim daily | achievementClaim (15/min) | Telegram + Active Season | - |
| Buy ticket | mutations (5/min) | Telegram + Active Season + Steam Verified | BuyTicketBodySchema |
| Get leaderboard | general (100/min) | Telegram | GetLeaderboardQuerySchema |
| Add to prize pool | mutations (5/min) | Admin JWT | AddToPrizePoolBodySchema |
| Cancel raffle | mutations (5/min) | Admin JWT | CancelRaffleBodySchema |
| Bot inventory | general (100/min) | Admin JWT | - |
См. Security Matrix для полного обзора защит.
Edge Cases
Что видит пользователь (UI):
| Ситуация | UI поведение |
|---|---|
| Уже забрал сегодня | Кнопка disabled, таймер до следующего claim |
| Баланс < цены билета | Кнопка disabled, tooltip "Недостаточно SP" |
| Max билетов куплено | Кнопка disabled, tooltip "Лимит достигнут" |
| Steam не привязан | Ошибка STEAM_NOT_LINKED, redirect на привязку |
| Steam не верифицирован | Ошибка STEAM_NOT_VERIFIED, показать инструкцию |
| 0 билетов при draw | Приз переносится на следующую неделю (rollover) |
| Пул призов пуст | Админ получает Telegram уведомление, розыгрыш не создаётся |
| Розыгрыш отменён | SP возвращаются участникам, Telegram уведомление |
| Предмет не на боте | Не участвует в weighted random выборе |
Backend Error Codes (для API/тестов)
| Код | HTTP | Сообщение |
|---|---|---|
ALREADY_CLAIMED | 400 | "Already claimed today" |
STEAM_NOT_LINKED | 400 | "Для участия необходимо привязать Steam" |
STEAM_NOT_VERIFIED | 400 | "Steam аккаунт не верифицирован" |
RAFFLE_NOT_FOUND | 404 | "Raffle not found" |
RAFFLE_NOT_ACTIVE | 400 | "Raffle is not active" |
MAX_TICKETS | 400 | "Maximum tickets reached" |
INSUFFICIENT_BALANCE | 400 | "Insufficient balance" |
PRIZE_POOL_EMPTY | 400 | "Prize pool is empty" |
ITEM_NOT_IN_BOT_INVENTORY | 400 | "Item not available on bot" |
RAFFLE_ALREADY_CANCELLED | 400 | "Raffle already cancelled" |
3. ADR (Architectural Decisions)
Почему Streak Points отдельная валюта, а не Scrap?
Проблема: Нужна мотивация заходить каждый день, но Scrap уже используется для кейсов и имеет свою экономику.
Решение: Отдельная валюта Streak Points (SP) с собственным источником (daily claim) и стоком (raffle tickets).
Альтернативы (отклонены):
- Начислять Scrap — размывает ценность основной валюты
- Прямые награды за стрик — менее гибкий контроль экономики
Последствия: Изолированная экономика лояльности. Требует отдельного балансирования, но не влияет на основной game loop.
Почему требуется верификация Steam для розыгрышей?
Проблема: Бот-аккаунты могут создавать множество профилей для увеличения шансов в розыгрыше.
Решение: Требуется привязанный + верифицированный Steam аккаунт для покупки билетов.
Верификация Steam — критическая защита от мультиаккаунтов в розыгрышах. Без неё система уязвима.
Последствия: Барьер для новых пользователей, но защита от абуза. Trade-off: потеря части casual аудитории vs. честность розыгрышей.
Почему продление розыгрыша вместо моментальной отмены?
Проблема: Розыгрыш может не набрать достаточно участников к дедлайну.
Решение: Автоматическое продление на 3 дня (max 1 раз). Если после продления условия не выполнены — отмена с полным рефандом.
Альтернативы (отклонены):
- Моментальная отмена — плохой UX для участников
- Розыгрыш с любым количеством — слишком простой win для малого числа участников
Последствия: Справедливость для участников + мотивация привлекать друзей.
Почему Raffle не вынесен в отдельный домен?
Проблема: Raffle имеет свою бизнес-логику (билеты, draw, prize pool) — возникает вопрос, нужен ли отдельный домен /domains/raffle/.
Решение: Оставить Raffle внутри Streaks домена.
Причины:
- Функциональная зависимость: Raffle использует
StreakPointsService.spend()— это не просто "лежат рядом", а прямая связь через валюту - Единая экосистема: Streaks генерирует SP → Raffle тратит SP. Это один game loop лояльности
- Разделение потребует: 2-4 часа работы (новый домен, перенос 6+ файлов, решение проблемы с общим StreakPointsService, обновление импортов, тестирование)
- YAGNI: Нет бизнес-причины для разделения — "для чистоты" не оправдывает усложнение
Альтернативы (отклонены):
- Вынести Raffle в
/domains/raffle/— кросс-доменная зависимость от StreakPoints останется, формальное разделение без реальной изоляции - Вынести StreakPoints в
/domains/economy/— нужен третий домен, ещё больше работы
Когда пересмотреть:
- Raffle станет независимым (другая валюта, не SP)
- Команда разделится (разные люди пилят streaks vs raffle)
- Raffle значительно вырастет в сложности
Последствия: Меньше файлов, проще понять flow. Trade-off: менее "чистая" структура, но pragmatic choice.
Почему streak tracking и reward claiming разделены?
Проблема: До исправления dailyLoginStreak изменялся в двух местах:
StreakService.checkAndUpdateStreak()— при login (основан наlastLoginDate)StreakRewardsService.incrementStreakOnClaim()— при claim (основан наlastStreakPointsClaim)
Баг: Пользователь заходил утром (streak 6→7), затем забирал награду вечером (streak 7→8). Это вызывало двойной инкремент, стрик "перепрыгивал" tier, что приводило к некорректному reset.
Решение: Применён Single Responsibility Principle:
dailyLoginStreakуправляется только черезcheckAndUpdateStreak()на основеlastLoginDate- Claim reward не изменяет стрик, а только читает текущее значение для расчёта награды
lastStreakPointsClaimобновляется отдельно для контроля cooldown награды
Альтернативы (отклонены):
- Использовать одну дату для обеих систем — требует либо входа для claim, либо claim для инкремента стрика
- Синхронизировать
lastLoginDateиlastStreakPointsClaim— сложная логика, хрупкая при изменениях
Separation of Concerns: Login tracking (лояльность) и reward claiming (экономика) — разные ответственности с разными условиями.
Пользователь может зайти несколько раз в день, но забрать награду только один раз. Эти события не должны влиять друг на друга.
Последствия:
- Консистентность стрика гарантирована
- Невозможен двойной инкремент
- Проще тестировать (независимые системы)
- Легче добавлять новые фичи (например, бонусы за несколько входов в день не влияют на daily claim)
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| StreakService | backend/src/domains/streaks/services/streak.service.ts | Управление стриком (increment/reset) |
| StreakRewardsService | backend/src/domains/streaks/services/streak-rewards.service.ts | Claim daily reward |
| StreakPointsService | backend/src/domains/streaks/services/streak-points.service.ts | Credit/debit SP, decay |
| RaffleService | backend/src/domains/streaks/services/raffle.service.ts | Покупка билетов, draw |
| RafflePrizePoolService | backend/src/domains/streaks/services/raffle-prize-pool.service.ts | Управление пулом призов |
| DecayJob | backend/src/domains/streaks/jobs/decay.job.ts | Ежедневное списание при неактивности |
| RaffleDrawJob | backend/src/domains/streaks/jobs/raffle-draw.job.ts | Автоматический розыгрыш |
| RaffleNotificationService | backend/src/domains/streaks/services/raffle-notification.service.ts | Telegram уведомления участникам |
| BotInventoryCacheService | backend/src/domains/steam-trade-bot/services/bot-inventory-cache.service.ts | Кэш инвентаря Steam бота |
| User Routes | backend/src/domains/streaks/routes/streak.routes.ts | User API |
| Raffle Routes | backend/src/domains/streaks/routes/raffle.routes.ts | Raffle User API |
| Admin Routes | backend/src/domains/streaks/routes/raffle-admin.routes.ts | Admin API |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| User | Streak данные в User модели | dailyLoginStreak, bestDailyLoginStreak, streakPoints, streakPointsTotal, lastStreakPointsClaim |
| UserSeasonStats | Сезонный рекорд стрика | bestDailyLoginStreak (обновляется при инкременте, фиксируется при season reset) |
| Raffle | Конфигурация розыгрыша | prizeType, prizeItemId, endsAt, status, totalTicketsPool, userTicketLimit |
| RaffleTicket | Купленный билет | raffleId, userId, ticketNumber, pricePaid |
| RafflePrizePool | Пул призов для автоматического создания | itemId, weight, isActive, timesWon |
| StreakPointsTransaction | История транзакций SP | userId, amount, balance, type, description |
| UserActiveBuff | Streak Shield хранится здесь | buffType: STREAK_SHIELD, usesLeft |
Relationships
6. API Endpoints
- User: Streaks
- User: Raffle
- Admin: Raffle
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /api/streaks/stats | Статистика стрика (streak, shields, multiplier) | → |
| POST | /api/streaks/claim-daily | Забрать ежедневную награду | → |
| GET | /api/streaks/transactions | История транзакций SP | → |
| GET | /api/streaks/leaderboard | Лидерборд стриков | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /api/raffle/current | Текущий активный розыгрыш | → |
| POST | /api/raffle/buy-ticket | Купить билет(ы) | → |
| GET | /api/raffle/my-tickets | Мои билеты в розыгрыше | → |
| GET | /api/raffle/history | История розыгрышей | → |
| GET | /api/raffle/:raffleId/live | SSE поток событий | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/raffle/prize-pool | Список пула призов | → |
| POST | /admin/raffle/prize-pool | Добавить в пул | → |
| PUT | /admin/raffle/prize-pool/:id | Обновить вес/статус | → |
| DELETE | /admin/raffle/prize-pool/:id | Удалить из пула | → |
| GET | /admin/raffle/raffles | Все розыгрыши | → |
| GET | /admin/raffle/raffles/:id | Детали розыгрыша | → |
| POST | /admin/raffle/raffles/:id/cancel | Отмена с рефандом | → |
| POST | /admin/raffle/raffles/create-next | Создать из пула | → |
| POST | /admin/raffle/raffles/:id/manual-draw | Ручной розыгрыш | → |
| GET | /admin/raffle/bot-inventory | Инвентарь бота | → |
| POST | /admin/raffle/bot-inventory/refresh | Обновить кэш | → |
7. Related
- Daily Spins — Streak Spin открывается за Streak Points
- Cases — Streak Cases открываются за Streak Points
- Buffs — Streak Shield как тип баффа
- Steam Trade Bot — Интеграция для проверки инвентаря и отправки призов
- Quests — Квесты с условием
streakDaysдля достижения стрика