Skip to main content

Streaks & Raffle System

1. Summary

Goal: Механизм лояльности через ежедневные серии входов и еженедельные розыгрыши призов. Игрок заходит каждый день, накапливает Streak Points и тратит их на билеты в розыгрышах реальных скинов.

User Value: Дополнительная мотивация заходить ежедневно + реальный шанс выиграть скин без денежных вложений, только за регулярную активность. Путь: Ежедневный вход → Streak Points → Билеты → Розыгрыш → Скин в инвентарь.


2. Business Logic

Types of Streak Rewards

Доступ: Бесплатный, раз в день

Награда: Streak Points = BASE × multiplier + topBonus

Формула:

  • BASE = 50 SP
  • multiplier: ×1.0 → ×2.5 в зависимости от длины стрика
  • topBonus: +100 (1 место), +50 (2-3 место), +25 (4-10 место)

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

Core Mechanics

1. Streak Management (Login-Based)

Single Source of Truth

Счётчик 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):

  1. upsert USS — гарантирует существование записи
  2. 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.bestDailyLoginStreakUSS.bestDailyLoginStreakAll-Time
Сезон 1: стрик достиг 151515 (Season 1 USS)15
Сезон 1: стрик сбросился до 11515 (не меняется)15
Season Reset0 (обнуляется)15 (фиксируется навсегда)15
Сезон 2: стрик достиг 888 (Season 2 USS)MAX(15, 8) = 15
Streak Points Balance — Single Source

Баланс SP отдаётся только через Profile API (GET /api/users/profilestreakPoints). 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

Tier-Based Configuration

Параметры розыгрыша зависят от Item.tier скина-приза. Tier определяется ценой скина (TIER_1 = дешёвый, TIER_5 = дорогой).

TierБилетов всегоMin участниковUser Limit
TIER_150220% (10)
TIER_2100220% (20)
TIER_3150320% (30)
TIER_4200420% (40)
TIER_5250520% (50)

User Limit — максимум билетов на одного участника (% от общего пула). Без лимита топ-стрикеры с большим балансом SP могли бы скупать большинство билетов, убивая конкуренцию и мотивацию для остальных.

При создании розыгрыша система копирует параметры из RAFFLE_TIERS[item.tier] в Raffle для консистентности (даже если константы изменятся, текущий розыгрыш сохранит свои правила).

5. Prize Pool Management

Weighted Random Selection

Пул призов — коллекция скинов с весами для взвешенного выбора следующего приза.

ПараметрОписание
weightВес для random (1-100, default: 50)
isActiveАктивен ли в пуле
timesWonСтатистика — сколько раз выигран

Алгоритм выбора:

  1. Получить активные предметы (isActive: true)
  2. Фильтровать по наличию на Steam боте (через BotInventoryCacheService)
  3. 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 LimitAuthValidation
Get statsgeneral (100/min)Telegram-
Claim dailyachievementClaim (15/min)Telegram + Active Season-
Buy ticketmutations (5/min)Telegram + Active Season + Steam VerifiedBuyTicketBodySchema
Get leaderboardgeneral (100/min)TelegramGetLeaderboardQuerySchema
Add to prize poolmutations (5/min)Admin JWTAddToPrizePoolBodySchema
Cancel rafflemutations (5/min)Admin JWTCancelRaffleBodySchema
Bot inventorygeneral (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_CLAIMED400"Already claimed today"
STEAM_NOT_LINKED400"Для участия необходимо привязать Steam"
STEAM_NOT_VERIFIED400"Steam аккаунт не верифицирован"
RAFFLE_NOT_FOUND404"Raffle not found"
RAFFLE_NOT_ACTIVE400"Raffle is not active"
MAX_TICKETS400"Maximum tickets reached"
INSUFFICIENT_BALANCE400"Insufficient balance"
PRIZE_POOL_EMPTY400"Prize pool is empty"
ITEM_NOT_IN_BOT_INVENTORY400"Item not available on bot"
RAFFLE_ALREADY_CANCELLED400"Raffle already cancelled"

3. ADR (Architectural Decisions)

Почему Streak Points отдельная валюта, а не Scrap?

Проблема: Нужна мотивация заходить каждый день, но Scrap уже используется для кейсов и имеет свою экономику.

Решение: Отдельная валюта Streak Points (SP) с собственным источником (daily claim) и стоком (raffle tickets).

Альтернативы (отклонены):

  • Начислять Scrap — размывает ценность основной валюты
  • Прямые награды за стрик — менее гибкий контроль экономики

Последствия: Изолированная экономика лояльности. Требует отдельного балансирования, но не влияет на основной game loop.

Почему требуется верификация Steam для розыгрышей?

Проблема: Бот-аккаунты могут создавать множество профилей для увеличения шансов в розыгрыше.

Решение: Требуется привязанный + верифицированный Steam аккаунт для покупки билетов.

Anti-Abuse

Верификация 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

КомпонентПутьОписание
StreakServicebackend/src/domains/streaks/services/streak.service.tsУправление стриком (increment/reset)
StreakRewardsServicebackend/src/domains/streaks/services/streak-rewards.service.tsClaim daily reward
StreakPointsServicebackend/src/domains/streaks/services/streak-points.service.tsCredit/debit SP, decay
RaffleServicebackend/src/domains/streaks/services/raffle.service.tsПокупка билетов, draw
RafflePrizePoolServicebackend/src/domains/streaks/services/raffle-prize-pool.service.tsУправление пулом призов
DecayJobbackend/src/domains/streaks/jobs/decay.job.tsЕжедневное списание при неактивности
RaffleDrawJobbackend/src/domains/streaks/jobs/raffle-draw.job.tsАвтоматический розыгрыш
RaffleNotificationServicebackend/src/domains/streaks/services/raffle-notification.service.tsTelegram уведомления участникам
BotInventoryCacheServicebackend/src/domains/steam-trade-bot/services/bot-inventory-cache.service.tsКэш инвентаря Steam бота
User Routesbackend/src/domains/streaks/routes/streak.routes.tsUser API
Raffle Routesbackend/src/domains/streaks/routes/raffle.routes.tsRaffle User API
Admin Routesbackend/src/domains/streaks/routes/raffle-admin.routes.tsAdmin API

5. Database Schema

Models

МодельОписаниеКлючевые поля
UserStreak данные в 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История транзакций SPuserId, amount, balance, type, description
UserActiveBuffStreak Shield хранится здесьbuffType: STREAK_SHIELD, usesLeft

Relationships


6. API Endpoints

МетодЭндпоинтОписаниеDocs
GET/api/streaks/statsСтатистика стрика (streak, shields, multiplier)
POST/api/streaks/claim-dailyЗабрать ежедневную награду
GET/api/streaks/transactionsИстория транзакций SP
GET/api/streaks/leaderboardЛидерборд стриков

  • Daily Spins — Streak Spin открывается за Streak Points
  • Cases — Streak Cases открываются за Streak Points
  • Buffs — Streak Shield как тип баффа
  • Steam Trade Bot — Интеграция для проверки инвентаря и отправки призов
  • Quests — Квесты с условием streakDays для достижения стрика