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

Open Pool + Exponential Pricing

Раффл использует открытый пул без потолка билетов и экспоненциальную цену — каждый следующий билет дороже предыдущего. Это создаёт естественный diminishing returns без жёсткого лимита.

Pricing formula: price(n) = BASE × multiplier^(n-1)

ПараметрDefaultGlobalSettings KeyОписание
BASE100 SP (рекомендуется ~300)raffle_ticket_base_priceЦена первого билета (см. ADR о ценовой стратегии)
Multiplier1.10raffle_ticket_multiplierМножитель роста (10% за билет)
User Limit %0.70raffle_user_limit_percentМакс. доля от всех билетов на пользователя
Min Guaranteed3raffle_min_guaranteed_ticketsМинимум билетов, доступных всегда
Min Participants10raffle_min_participantsМин. участников для розыгрыша
Duration Days7raffle_duration_daysДлительность розыгрыша

Dynamic User Cap — максимум билетов на одного участника вычисляется динамически:

maxForUser = max(MIN_GUARANTEED, floor(totalTickets × userLimitPercent))
canBuy = max(0, maxForUser - currentUserTickets)

Примеры (limitPercent=0.70, minGuaranteed=3):

  • totalTickets=0 → maxForUser=3, canBuy=3
  • totalTickets=10 → maxForUser=7
  • totalTickets=100 → maxForUser=70

Frozen Config — при создании розыгрыша все 6 параметров копируются из GlobalSettings в 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 (cron 0 20 * * *) — проверка состояния розыгрышей
  • Длительность рафла — настраивается через Admin Panel (1–30 дней, по умолчанию 7 дней), хранится в GlobalSettings
  • Условие розыгрыша: endsAt <= now AND totalParticipants >= minParticipants
  • Если условия не выполнены и есть участники → продление: extensionDays = Math.max(1, Math.ceil(durationDays / 3))
    • При 7 днях → продление на 3 дня
    • При 3 днях → продление на 1 день
  • Максимум 1 продление → после этого отмена с рефандом и Telegram уведомлением
  • 0 билетов → приз переносится на следующий розыгрыш (rollover)
  • Если нет активного рафла при срабатывании cron → новый рафл создаётся автоматически из пула призов

7. Cancel & Refund

  • Админ может отменить активный розыгрыш с указанием причины
  • SP автоматически возвращаются всем участникам (тип: RAFFLE_REFUND)
  • Каждый участник получает персональное Telegram уведомление:
    • CANCELLED (админ): "Розыгрыш '{prizeTitle}' отменён администратором. Вам возвращено {amount} SP."
    • INSUFFICIENT_PARTICIPANTS (авто): "Розыгрыш '{prizeTitle}' не состоялся: недостаточно участников. Вам возвращено {amount} SP."

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не трогается (текущий стрик продолжается между сезонами)

10. Consolation Prize System (Tier-Based)

Tier-Based Distribution

Утешительные призы для проигравших в розыгрыше. Два типа распределения, оба привязаны к tier-системе на основе потраченных SP:

ТипМодельОписание
SCRAP / XP / SPTier-proportionalПул = amountPerTicket × totalTickets, делится по тирам (allocation%), внутри тира — пропорционально spentSP ±20%
ITEM (все ItemType)Кумулятивная лотереяEligible по minSP предмета, max 1 предмет на юзера, rare first
Frozen Config

Алгоритм использует frozenConsolationConfig из Raffle (snapshot тиров/валют/предметов при создании рафла), а НЕ текущие глобальные настройки. Изменения админа применяются к следующему раффлу.

Тиры (ConsolationTier):

Админ настраивает тиры с minSP (порог входа) и allocation (доля от пула). Сумма allocation всех тиров = 1.0.

Тир 1:   minSP=1    allocation=0.40  (40% пула)
Тир 200: minSP=200 allocation=0.35 (35% пула)
Тир 500: minSP=500 allocation=0.25 (25% пула)

Каждый лузер попадает в тир на основе spentSP — максимальный тир, где minSP <= spentSP.

Валютное распределение (SCRAP / XP / SP):

totalPool = amountPerTicket × totalTickets
tierPool = totalPool × tier.allocation

Внутри тира:
baseShare = (mySpentSP / totalTierSP) × tierPool
randomFactor = 0.8 + random() × 0.4 // [0.8, 1.2] ±20%
rawShare = baseShare × randomFactor
→ нормализация: scale все rawShare чтобы sum === tierPool
→ cap: min(finalShare, spentSP × MAX_MULTIPLIER) // MAX_MULTIPLIER = 2
→ excess перераспределяется uncapped юзерам (до 5 итераций)
→ remainder → лузеру с max spentSP в тире
  • amountPerTicket — сколько валюты добавляет каждый купленный билет в пул (настраивается админом)
  • totalTickets — общее количество билетов в рафле
  • spentSP = SUM(RaffleTicket.pricePaid) — реально потраченные SP на билеты

Per-currency tier gating (minTierSP):

Каждая валюта имеет minTierSP — минимальный тир для получения:

SCRAP  minTierSP=1    → все тиры получают скрап
XP minTierSP=200 → только тир 200+ получают XP
SP minTierSP=500 → только тир 500+ получают SP

Gated тиры (ниже minTierSP) получают tierPool=0. Мотивирует тратить больше SP — высокие тиры получают не просто больше валюты, а больше видов валюты.

Каскадное перераспределение пустых тиров:

Если тир пуст (0 eligible лузеров) → его tierPool каскадирует ВНИЗ (от высшего к низшему minSP). Тир 1 (minSP=1) — гарантированный fallback. Каскад не затрагивает gated тиры.

Per-user cap:

MAX_MULTIPLIER = 2. Cap = spentSP × MAX_MULTIPLIER. Защита от edge case: мало участников в тире → один игрок получает непропорционально много. Excess от capped юзеров перераспределяется uncapped (до 5 итераций), если все capped — excess undistributed.

Предметное распределение (ITEM):

Предметы сортируются по minSP DESC (сначала редкие):

eligible = losers.filter(l => l.spentSP >= item.minSP AND !alreadyWonItem)
raw = multiplier × eligible.length
instances = raw >= 1.0 ? floor(raw) : (random() < raw ? 1 : 0)
winners = randomSample(eligible, instances) // Fisher-Yates
→ winners исключаются из eligible для следующих предметов
multiplier1 eligible10 eligible100 eligible
0.055% шанс50% шанс5 экз.
0.110% шанс1 экз.10 экз.
1.01 экз.10 экз.100 экз.

Max 1 предмет на юзера — цель consolation — смягчить проигрыш, а не раздать лут. Порядок по minSP DESC гарантирует, что высокие спендеры сначала участвуют в розыгрыше редких предметов.

11. Consolation Claim Flow

Паттерн: как промокоды + рефералы

Результат распределения сохраняется в JSON snapshot (ConsolationAward.rewards). Claim по кнопке "Забрать" в модалке, batch API при последнем клике.

  1. Раффл завершается → distributeConsolation() создаёт ConsolationAward записи с claimed=false и tierMinSP
  2. Лузер заходит → модалка с утешительными призами (carousel при нескольких незабранных)
  3. Per-raffle "Забрать" → чекмарк → auto-advance → при последнем клике → POST /api/raffle/claim-consolation
  4. $transaction: начисление всех наград из rewards JSON + claimed=true
Тип наградыCreditingАудит
XPuser.xp += amountrewardSnapshot
SCRAPuser.scrapBalance += amountrewardSnapshot
SPuser.streakPoints += amountStreakPointsTransaction(type: RAFFLE_CONSOLATION)
ITEMUserInventory.create(source: RAFFLE_CONSOLATION)inventory record
  • Dismiss = reset: закрыл модалку — ничего не отправлено, покажется снова при следующем входе
  • Порядок модалок: Рефералы → Результат раффла (победа ИЛИ утешительные, взаимоисключающие)
  • ConsolationClaimModal: TieredLeaderboard стиль (аналогично preview), показывает полученные награды по тирам
  • Отмена раффла: SP возвращаются, утешительные НЕ разыгрываются

12. Consolation Preview

Кнопка "Утешительные призы" в модалке розыгрыша → TieredLeaderboard-стиль модалка:

✅ Тир: 1 SP     │ Пул: 500 SCRAP              ← unlocked
│ 🎁 Pixel Hoodie (шанс 50%)
│ ▓▓▓▓▓▓▓░░░░ ещё 150 SP до тира 200

✅ Тир: 200 SP │ Пул: 1,000 SCRAP │ 2,000 XP ← ВЫ ЗДЕСЬ
│ 🎁 + Fragment M4

🔒 Тир: 500 SP │ Пул: 800 SCRAP │ 1,500 XP │ 50 SP ← locked

GET /api/raffle/:raffleId/consolation-preview → возвращает:

{
frozenConfig: { tiers, currencies, items },
liveStats: { totalTickets },
userSpentSP
}

Пулы пересчитываются клиентом в real-time: pool = amountPerTicket × totalTickets × allocation. SSE event ticket_purchased обновляет totalTickets.

Slot-machine анимация: При обновлении пула (SSE ticket_purchased) цифры прокручиваются как на слот-машине — каждый разряд числа "крутится" сверху вниз до нового значения.

13. Consolation Admin Settings

КлючТипDefaultОписание
consolation_enabledBooleantrueОбщий тумблер

Consolation Tier Management — админ настраивает три сущности через отдельные CRUD:

  • Тиры (ConsolationTier): minSP (порог SP), allocation (доля от пула, 0.0-1.0). Сумма allocation = 1.0 — есть кнопка валидации
  • Валюты (ConsolationCurrency): type (SCRAP/XP/SP), amountPerTicket (вклад за билет), minTierSP (минимальный тир). Max 1 запись на тип
  • Предметы (ConsolationItem): itemId, multiplier (0.01-1.0), minSP, isActive

Конфигурация фиксируется в frozenConsolationConfig при создании рафла (и при rollover). Изменения админа применяются к следующему раффлу.

Admin UI: Inline editing тиров в таблице (без отдельной страницы). Кнопка валидации проверяет sum(allocation) = 1.0.

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
SSE live feedNoneNone (public)-
Add to prize poolmutations (5/min)Admin JWTAddToPrizePoolBodySchema
Cancel rafflemutations (5/min)Admin JWTCancelRaffleBodySchema
Bot inventorygeneral (100/min)Admin JWT-
Get raffle settingsgeneral (100/min)Admin JWT-
Update raffle settingsmutations (5/min)Admin JWTRaffleSettingsBodySchema (durationDays 1-30, minParticipants 2-100, basePrice 10-10000, multiplier 1.01-2.00, limitPercent 0.10-0.90, minGuaranteed 1-20)
Get unclaimed consolationgeneral (100/min)Telegram-
Claim consolationmutations (5/min)Telegram- (без body, сервер забирает все unclaimed)
Get consolation previewgeneral (100/min)TelegramRaffleIdParamSchema
Consolation tier/currency/item CRUDmutations (5/min)Admin JWTPer-entity schemas
Consolation validatemutations (5/min)Admin JWT
Consolation settingsmutations (5/min)Admin JWTConsolationSettingsSchema
Детали реализации

См. Security Matrix для полного обзора защит.

Edge Cases

Что видит пользователь (UI):

СитуацияUI поведение
Уже забрал сегодняКнопка disabled, таймер до следующего claim
Баланс < цены билетаКнопка disabled, tooltip "Недостаточно SP"
Max билетов куплено (dynamic cap)Кнопка disabled, tooltip "Лимит достигнут" (лимит пересчитывается при каждой покупке)
Steam не привязанОшибка STEAM_NOT_LINKED, redirect на привязку
Steam не верифицированОшибка STEAM_NOT_VERIFIED, показать инструкцию
0 билетов при drawПриз переносится на следующую неделю (rollover)
Пул призов пустАдмин получает Telegram уведомление, розыгрыш не создаётся
Розыгрыш отменён (админ)SP возвращаются, персональное Telegram сообщение с суммой возврата
Розыгрыш не состоялся (мало участников)SP возвращаются, Telegram сообщение "недостаточно участников"
Предмет не на ботеНе участвует в weighted random выборе
0 лузеров в розыгрышеConsolation distribution skip
Лузер не забрал утешительныеМодалка при каждом входе (persist до claimed)
2+ незабранных розыгрышейCarousel (паттерн рефералов)
Dismiss карусели посерединеНичего не отправлено, при следующем входе все unclaimed
Все minSP > SP лузера (пустой bag)ConsolationAward не создаётся
Предмет удалён из ItemSnapshot (frozenConfig) сохраняет данные для отображения
Раффл отменёнSP возвращаются, утешительные НЕ разыгрываются
Race condition claim$transaction + проверка claimed=false атомарно
Админ меняет тиры/валюты/предметы во время раффлаИзменения не затрагивают текущий рафл (frozenConsolationConfig)
Дубль валюты в пулеНевозможен — @@unique по type для ConsolationCurrency
Пустой тир (0 eligible лузеров)Пул каскадирует вниз к следующему тиру
Все тиры пусты кроме базовогоВесь пул идёт в базовый тир (minSP=1)
Сумма allocation ≠ 1.0Validation endpoint + warning banner в admin UI
Нет frozenConsolationConfigPreview возвращает null, consolation отключен для рафла
Per-user cap (spentSP × 2)Excess undistributed (сгорает)
Юзер уже получил предметИсключается из eligible для следующих предметов (max 1 item)
Preview не обновляется после покупки билетаinvalidateQueries(CONSOLATION_PREVIEW_QUERY_KEY) в useRaffle.onSuccess — userSpentSP и тир актуализируются
minSP=0 в тиреНевозможен — валидация minSP >= 1 (участник всегда имеет spentSP >= ticketBasePrice >= 1)
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)

Почему длительность рафла хранится в GlobalSettings?

Проблема: Частота рафлов была захардкожена в константах (DURATION_DAYS = 7) и cron-выражениях. Изменить расписание без правки кода и редеплоя было невозможно.

Решение: Вынести durationDays в таблицу GlobalSettings (key-value: raffle_duration_days). Значение читается при создании каждого нового рафла, что позволяет менять частоту через Admin Panel без деплоя. При отсутствии ключа в БД — fallback = 7 дней.

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

  • Новое поле в Prisma schema — требует миграции, избыточно для одного числа
  • ENV-переменная — требует деплоя при смене; GlobalSettings уже есть как инфраструктура с upsert-паттерном

Последствия: Изменение длительности вступает в силу при создании следующего рафла после завершения текущего. Admin Panel (вкладка "Общие") предупреждает об этом.

Почему Open Pool + Exponential Pricing вместо Tier-Based?

Проблема: Фиксированный пул билетов (TIER_1=50, TIER_5=250) создавал искусственный потолок. При высокой цене билетов и minTicketsToStart пул мог никогда не заполниться — розыгрыш не запускался, приз переносился бесконечно. Система сама себя ограничивала.

Решение: Open Pool + Exponential Pricing:

  1. Нет потолка билетов — покупка до endsAt
  2. Экспоненциальная цена: price(n) = BASE × multiplier^(n-1) — встроенный diminishing returns
  3. Динамический лимит: max(MIN_GUARANTEED, floor(totalTickets × userLimitPercent)) — предотвращает монополию
  4. Условие розыгрыша: только minParticipants (убран minTicketsToStart)
  5. Все параметры настраиваются через Admin Panel и замораживаются при создании рафла

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

  • Увеличить пулы тиров — не решает проблему потолка, просто сдвигает
  • Линейное удорожание — слабый diminishing returns, крупные игроки всё равно скупают
  • Нет лимита на пользователя — один игрок монополизирует весь розыгрыш

Последствия: Система масштабируется без ручного тюнинга тиров. Админ контролирует 6 параметров через UI. Каждый рафл замораживает конфиг при создании.

Почему все raffle-параметры хранятся в GlobalSettings?

Проблема: Tier-based конфигурация была захардкожена в коде — для изменения параметров требовался деплой.

Решение: Все 6 параметров рафла хранятся в GlobalSettings (key-value): raffle_duration_days, raffle_min_participants, raffle_ticket_base_price, raffle_ticket_multiplier, raffle_user_limit_percent, raffle_min_guaranteed_tickets. Изменяются через Admin Panel без деплоя.

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

  • ENV-переменные — требуют деплоя при смене
  • Отдельная Prisma модель — избыточно для 6 скаляров

Последствия: Изменения вступают в силу при создании следующего рафла. Текущие ACTIVE рафлы не затрагиваются (значения замораживаются при создании). Admin Panel показывает preview цен при изменении BASE/multiplier.

Почему один cron 0 20 * * * вместо двух расписаний?

Проблема: Было два cron: 0 20 * * 0 (воскресенье, draw + создать следующий) и 5 20 * * 1-6 (остальные дни, только draw). Привязка к воскресенью потеряла смысл с появлением динамической длительности — рафл может закончиться в любой день недели.

Решение: Один cron 0 20 * * * (каждый день в 20:00 UTC). При каждом срабатывании:

  1. processReadyRaffles() — завершает рафлы с истёкшим endsAt
  2. Если нет активного рафла → createRaffleFromPool() (идемпотентная guard-проверка)

Рафл сам знает когда заканчиваться через поле endsAt, привязка к конкретному дню недели не нужна.

Последствия: Новый рафл создаётся в тот же день, когда закончился предыдущий. Логика становится проще и независима от дня недели.

Почему базовая цена билета должна быть высокой (~300 SP)?

Проблема: При низкой базовой цене (100 SP) и большом количестве участников (~1000) покупка дополнительных билетов психологически бессмысленна. Разница в шансе между 1 и 3 билетами — 0.17% vs 0.43% — не ощущается игроком. Мотивация тратить SP на дополнительные билеты практически нулевая.

Решение: Установить raffle_ticket_base_price в районе 300 SP через Admin Panel. Это создаёт дефицит билетов — не все игроки могут позволить себе даже один билет каждый розыгрыш.

Механика "Savings Dilemma":

При BASE=300 SP и цикле розыгрыша 3 дня (~300 SP заработка для обычного игрока):

  • ~65% игроков могут купить 1 билет (потратив весь заработок)
  • ~35% могут купить 2 билета (активные игроки + те, кто копил)
  • ~20% могут купить 3+ билетов (те, кто пропускал предыдущие розыгрыши)

Стратегическая дилемма для игрока: "Купить 1 билет сейчас или скопить на 2-3 билета в следующем розыгрыше?"

Почему это работает:

Участников1 билет3 билетаПреимущество
100 (при BASE=300)1.0%3.0%×3 — ощутимо
2000.5%1.5%×3 — всё ещё заметно
502.0%6.0%×3 — весомо

При меньшем пуле участников каждый билет имеет бо́льший вес, и разница между 1 и 3 билетами ощутима.

Рекомендации по настройке:

ПризBASEОбоснование
$1-10 (дешёвый скин)250-350 SPДефицит, но доступно за 1 цикл
$10-30 (средний скин)400-600 SPТребует накопления, высокая ценность билета
$30+ (дорогой скин)700-1000 SPЭлитный розыгрыш, только для dedicated игроков
Все параметры настраиваются через Admin Panel

Изменение raffle_ticket_base_price в Admin Panel → Settings → Raffle вступает в силу при создании следующего рафла. Текущий активный розыгрыш не затрагивается (Frozen Config).

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

  • Снизить цену для массового участия — размывает ценность билета, нет мотивации покупать больше одного
  • Фиксированный пул билетов по тирам — противоречит Open Pool модели и создаёт искусственные ограничения
  • Мульти-победители или утешительные призы — избыточная сложность для текущего масштабаРеализовано в v2 (см. Consolation Prize System)

Последствия: Меньше участников за розыгрыш, но каждый билет имеет реальный вес. Игроки, которые копят SP и покупают 2-3 билета, получают ощутимое преимущество (×2-3) по сравнению с теми, кто покупает один.

Почему tier-based distribution вместо flat pool?

Проблема: В предыдущей версии (flat pool) все лузеры делили один пул пропорционально spentSP. Это не создавало мотивации тратить значительно больше — разница между 100 и 200 SP была линейной. Не было ощущения "прогресса" или "уровней".

Решение: Tier-based распределение: лузеры распределяются по тирам на основе spentSP, каждый тир получает свою долю пула (allocation%). Высокие тиры: меньше людей → бОльшая доля на человека. Дополнительно: per-currency tier gating — некоторые валюты доступны только высоким тирам.

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

  • Flat pool (v2) — не мотивирует тратить больше, нет ощущения прогресса
  • Полностью случайное — несправедливо к активным игрокам
  • Фиксированные награды по тирам — не масштабируется от количества участников

Последствия: Больше мотивации покупать билеты. Ощущение "тир-листа" — видно куда расти. Пул масштабируется через totalTickets. Сложнее балансировать — нужно следить за allocation%.

Почему пул привязан к totalTickets, а не к totalParticipants?

Проблема: amountPerPlayer × totalParticipants не учитывает, сколько реально потратили участники. 100 участников по 1 билету и 100 участников по 5 билетов создавали одинаковый пул.

Решение: amountPerTicket × totalTickets — пул линейно растёт от общего количества билетов. Больше билетов куплено → больше пул → больше наград для всех лузеров.

Последствия: Пул адекватно отражает общую активность. Мотивирует экосистему: даже проигравшие "побочно" увеличивают consolation пул для других.

Почему лотерейная модель для предметов с max 1 на юзера?

Проблема: Предметы — ограниченный ресурс. Без лимита один удачливый юзер может забрать 2-3 предмета, остальные — 0.

Решение: instances = multiplier × eligible, Fisher-Yates выбор. Предметы сортируются по minSP DESC (сначала редкие). Max 1 предмет на юзера — победитель исключается из eligible для следующих предметов. При малом eligible — вероятностный шанс (random() < raw) для анти-абуза.

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

  • Без лимита — один игрок забирает всё
  • Привязка к тирам — слишком жёстко, minSP предмета уже фильтрует
  • ceil() при малом eligible — один абузер гарантированно получает

Последствия: Справедливое распределение, один предмет уже "смягчает проигрыш". Порядок rare-first гарантирует, что высокие спендеры сначала участвуют в розыгрыше ценных предметов.

Почему JSON snapshot для rewards?

Проблема: Предметы могут быть удалены/изменены после распределения. Нужно сохранить данные для отображения.

Решение: ConsolationAward.rewards — JSON snapshot bag'а призов с enriched данными (itemName, imageUrl, tier). Паттерн идентичен промокодам.

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

  • FK на Item + отдельные поля — хрупко при удалении предметов
  • Отдельная таблица ConsolationReward — избыточная нормализация для read-only данных

Последствия: Полная автономность отображения от текущего состояния каталога.

Почему frozenConsolationConfig при создании рафла?

Проблема: Админ может изменить тиры/валюты/предметы во время активного рафла. Это приведёт к inconsistency: preview показывает одно, distribution считает другое.

Решение: Snapshot тиров/валют/предметов (frozenConsolationConfig) создаётся при создании рафла и при rollover. Алгоритм распределения использует только frozen config, не глобальные настройки.

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

  • Использовать текущие настройки при distribution — race condition, inconsistency с preview
  • Блокировать изменения при активном рафле — плохой UX для админа

Последствия: Предсказуемость: preview показывает то, что реально будет распределено. Админ свободно меняет настройки — они применяются к следующему раффлу.

Почему per-currency tier gating (minTierSP)?

Проблема: При tier-based distribution все тиры получают все валюты, различие только в количестве. Нет качественного различия между тирами.

Решение: Каждая валюта имеет minTierSP. Тиры ниже порога не получают эту валюту вообще. Высокие тиры получают не просто больше валюты, а больше видов валюты.

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

  • Только количественное различие — слабая мотивация подниматься в тире
  • Уникальные предметы по тирам — слишком сложно для админа

Последствия: Геймдизайн: "хочешь SP в consolation — потрать хотя бы 500 SP на билеты". Создаёт многослойную мотивацию.

Почему per-user cap (MAX_MULTIPLIER = 2)?

Проблема: При малом количестве участников в тире один игрок может получить непропорционально много валюты.

Решение: Cap = spentSP × MAX_MULTIPLIER (MAX_MULTIPLIER = 2). Пул и так масштабируется через totalTickets (мало людей → маленький пул), поэтому cap — страховка, а не основной механизм. Excess undistributed (не каскадируется).

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

  • Каскад excess в другие тиры — усложняет логику, нарушает изоляцию тиров
  • Нет cap — edge case с непропорциональным обогащением
  • Жёсткий фиксированный лимит — не учитывает вклад игрока

Последствия: Справедливость при edge cases. Простая формула. Excess "сгорает" — допустимо, т.к. ситуация редкая.


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
RaffleLifecycleServicebackend/src/domains/streaks/services/raffle-lifecycle.service.tsRollover/extend логика розыгрышей
RaffleDrawServicebackend/src/domains/streaks/services/raffle-draw.service.tsЛогика проведения розыгрыша
RaffleAdminServicebackend/src/domains/streaks/services/raffle-admin.service.tsАдминистративные операции розыгрышей
RafflePrizePoolServicebackend/src/domains/streaks/services/raffle-prize-pool.service.tsУправление пулом призов
RaffleNotificationServicebackend/src/domains/streaks/services/raffle-notification.service.tsTelegram уведомления участникам
RaffleUtilsbackend/src/domains/streaks/services/raffle-utils.tsВспомогательные функции для розыгрышей
ConsolationDistributionServicebackend/src/domains/streaks/services/consolation-distribution.service.tsTier-based распределение утешительных призов
ConsolationTierServicebackend/src/domains/streaks/services/consolation-tier.service.tsCRUD тиров, валют, предметов + buildFrozenConfig
ConsolationClaimServicebackend/src/domains/streaks/services/consolation-claim.service.tsClaim flow + preview (из frozenConfig)
StreakRepositorybackend/src/domains/streaks/repositories/streak.repository.tsРепозиторий стриков
StreakPointsRepositorybackend/src/domains/streaks/repositories/streak-points.repository.tsРепозиторий транзакций SP
RaffleRepositorybackend/src/domains/streaks/repositories/raffle.repository.tsРепозиторий розыгрышей
RafflePrizePoolRepositorybackend/src/domains/streaks/repositories/raffle-prize-pool.repository.tsРепозиторий пула призов
DecayJobbackend/src/domains/streaks/jobs/decay.job.tsЕжедневное списание при неактивности
RaffleDrawJobbackend/src/domains/streaks/jobs/raffle-draw.job.tsАвтоматический розыгрыш
DailyRewardsJobbackend/src/domains/streaks/jobs/daily-rewards.job.tsЕжедневный сбор статистики наград
BotInventoryCacheServicebackend/src/domains/steam-trade-bot/services/bot-inventory-cache.service.tsКэш инвентаря Steam бота
User Routesbackend/src/domains/streaks/routes/user-streaks.routes.tsUser API
Raffle Routesbackend/src/domains/streaks/routes/user-raffles.routes.tsRaffle User API
Admin Routesbackend/src/domains/streaks/routes/admin-raffles.routes.tsAdmin API

5. Database Schema

Models

МодельОписаниеКлючевые поля
UserStreak данные в User моделиdailyLoginStreak, bestDailyLoginStreak, streakPoints, streakPointsTotal, lastStreakPointsClaim
UserSeasonStatsСезонный рекорд стрикаbestDailyLoginStreak (обновляется при инкременте, фиксируется при season reset)
RaffleКонфигурация розыгрышаprizeType, prizeItemId, endsAt, status, ticketBasePrice, ticketMultiplier, userLimitPercent, minGuaranteedTickets, minParticipants, frozenConsolationConfig (Json?)
RaffleTicketКупленный билетraffleId, userId, ticketNumber, pricePaid
RafflePrizePoolПул призов для автоматического созданияitemId, weight, isActive, timesWon
ConsolationTierТир утешительных призовminSP, allocation (доля от пула, 0.0-1.0)
ConsolationCurrencyВалюта в consolation пулеtype (ConsolationCurrencyType, unique), amountPerTicket, minTierSP
ConsolationItemПредмет в consolation пулеitemId (unique), multiplier (0.01-1.0), minSP, isActive
ConsolationAwardРезультат распределения утешительных призовraffleId, userId, spentSP, tierMinSP (Int?), rewards (Json snapshot), claimed, claimedAt
StreakPointsTransactionИстория транзакций SPuserId, amount, balance, type (включая RAFFLE_CONSOLATION), description
UserActiveBuffStreak Shield хранится здесьbuffType: STREAK_SHIELD, usesLeft

Enums (consolation-related):

  • ConsolationCurrencyType: SCRAP, XP, SP
  • InventorySourceType: включает RAFFLE_CONSOLATION
  • StreakPointsTransactionType: включает RAFFLE_CONSOLATION

Raffle.frozenConsolationConfig (Json, nullable) — snapshot тиров/валют/предметов при создании рафла:

{
"tiers": [{ "minSP": 1, "allocation": 0.4 }, { "minSP": 200, "allocation": 0.35 }, { "minSP": 500, "allocation": 0.25 }],
"currencies": [{ "type": "SCRAP", "amountPerTicket": 10, "minTierSP": 1 }, { "type": "XP", "amountPerTicket": 20, "minTierSP": 200 }],
"items": [{ "itemId": "...", "name": "Fragment M4", "imageUrl": "...", "multiplier": 0.1, "minSP": 500 }]
}

Raffle.consolationMeta (Json, nullable) — аудит распределения (ConsolationMetaV2):

{
"totalParticipants": 150, "totalLosers": 149, "totalTickets": 420,
"tiers": {
"SCRAP": [{ "tierMinSP": 1, "allocation": 0.4, "tierPool": 1680, "eligibleCount": 100, "distributed": 1680 }]
},
"items": { "Fragment M4": { "eligible": 30, "raw": 3.0, "instances": 3 } }
}

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 для достижения стрика