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-20 | ×1.5 |
| 21-27 | ×2.0 |
| 28+ | ×2.5 (max) |
Пример: День 30 = 50 × 2.5 = 125 SP
Доступ: За Streak Points
Цена билетов (экспоненциальная):
Формула: price(n) = BASE × multiplier^(n-1), где BASE и multiplier настраиваются через Admin Panel.
| Билет # | Цена (при BASE=100, mult=1.10) | Cumulative |
|---|---|---|
| 1 | 100 SP | 100 SP |
| 5 | 146 SP | 611 SP |
| 10 | 236 SP | 1,593 SP |
| 20 | 612 SP | 5,727 SP |
| 50 | 10,672 SP | 109,813 SP |
Цель: Один из 3 sink'ов SP (вместе со Spin и Case). Экспоненциальная цена создаёт естественный diminishing returns — каждый следующий билет дороже.
Что это: Защита стрика от сброса при пропуске дня
Механика: При пропуске дня система автоматически использует 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
Раффл использует открытый пул без потолка билетов и экспоненциальную цену — каждый следующий билет дороже предыдущего. Это создаёт естественный diminishing returns без жёсткого лимита.
Pricing formula: price(n) = BASE × multiplier^(n-1)
| Параметр | Default | GlobalSettings Key | Описание |
|---|---|---|---|
| BASE | 100 SP (рекомендуется ~300) | raffle_ticket_base_price | Цена первого билета (см. ADR о ценовой стратегии) |
| Multiplier | 1.10 | raffle_ticket_multiplier | Множитель роста (10% за билет) |
| User Limit % | 0.70 | raffle_user_limit_percent | Макс. доля от всех билетов на пользователя |
| Min Guaranteed | 3 | raffle_min_guaranteed_tickets | Минимум билетов, доступных всегда |
| Min Participants | 10 | raffle_min_participants | Мин. участников для розыгрыша |
| Duration Days | 7 | raffle_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
Пул призов — коллекция скинов с весами для взвешенного выбора следующего приза.
| Параметр | Описание |
|---|---|
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 (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-системе на основе потраченных SP:
| Тип | Модель | Описание |
|---|---|---|
| SCRAP / XP / SP | Tier-proportional | Пул = amountPerTicket × totalTickets, делится по тирам (allocation%), внутри тира — пропорционально spentSP ±20% |
| ITEM (все ItemType) | Кумулятивная лотерея | Eligible по minSP предмета, max 1 предмет на юзера, rare first |
Алгоритм использует 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 для следующих предметов
| multiplier | 1 eligible | 10 eligible | 100 eligible |
|---|---|---|---|
| 0.05 | 5% шанс | 50% шанс | 5 экз. |
| 0.1 | 10% шанс | 1 экз. | 10 экз. |
| 1.0 | 1 экз. | 10 экз. | 100 экз. |
Max 1 предмет на юзера — цель consolation — смягчить проигрыш, а не раздать лут. Порядок по minSP DESC гарантирует, что высокие спендеры сначала участвуют в розыгрыше редких предметов.
11. Consolation Claim Flow
Результат распределения сохраняется в JSON snapshot (ConsolationAward.rewards). Claim по кнопке "Забрать" в модалке, batch API при последнем клике.
- Раффл завершается →
distributeConsolation()создаётConsolationAwardзаписи сclaimed=falseиtierMinSP - Лузер заходит → модалка с утешительными призами (carousel при нескольких незабранных)
- Per-raffle "Забрать" → чекмарк → auto-advance → при последнем клике →
POST /api/raffle/claim-consolation $transaction: начисление всех наград из rewards JSON +claimed=true
| Тип награды | Crediting | Аудит |
|---|---|---|
| XP | user.xp += amount | rewardSnapshot |
| SCRAP | user.scrapBalance += amount | rewardSnapshot |
| SP | user.streakPoints += amount | StreakPointsTransaction(type: RAFFLE_CONSOLATION) |
| ITEM | UserInventory.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_enabled | Boolean | true | Общий тумблер |
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 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 |
| SSE live feed | None | None (public) | - |
| 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 | - |
| Get raffle settings | general (100/min) | Admin JWT | - |
| Update raffle settings | mutations (5/min) | Admin JWT | RaffleSettingsBodySchema (durationDays 1-30, minParticipants 2-100, basePrice 10-10000, multiplier 1.01-2.00, limitPercent 0.10-0.90, minGuaranteed 1-20) |
| Get unclaimed consolation | general (100/min) | Telegram | - |
| Claim consolation | mutations (5/min) | Telegram | - (без body, сервер забирает все unclaimed) |
| Get consolation preview | general (100/min) | Telegram | RaffleIdParamSchema |
| Consolation tier/currency/item CRUD | mutations (5/min) | Admin JWT | Per-entity schemas |
| Consolation validate | mutations (5/min) | Admin JWT | — |
| Consolation settings | mutations (5/min) | Admin JWT | ConsolationSettingsSchema |
См. 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 не создаётся |
| Предмет удалён из Item | Snapshot (frozenConfig) сохраняет данные для отображения |
| Раффл отменён | SP возвращаются, утешительные НЕ разыгрываются |
| Race condition claim | $transaction + проверка claimed=false атомарно |
| Админ меняет тиры/валюты/предметы во время раффла | Изменения не затрагивают текущий рафл (frozenConsolationConfig) |
| Дубль валюты в пуле | Невозможен — @@unique по type для ConsolationCurrency |
| Пустой тир (0 eligible лузеров) | Пул каскадирует вниз к следующему тиру |
| Все тиры пусты кроме базового | Весь пул идёт в базовый тир (minSP=1) |
| Сумма allocation ≠ 1.0 | Validation endpoint + warning banner в admin UI |
| Нет frozenConsolationConfig | Preview возвращает 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_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)
Почему длительность рафла хранится в 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:
- Нет потолка билетов — покупка до
endsAt - Экспоненциальная цена:
price(n) = BASE × multiplier^(n-1)— встроенный diminishing returns - Динамический лимит:
max(MIN_GUARANTEED, floor(totalTickets × userLimitPercent))— предотвращает монополию - Условие розыгрыша: только
minParticipants(убранminTicketsToStart) - Все параметры настраиваются через 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). При каждом срабатывании:
processReadyRaffles()— завершает рафлы с истёкшимendsAt- Если нет активного рафла →
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 — ощутимо |
| 200 | 0.5% | 1.5% | ×3 — всё ещё заметно |
| 50 | 2.0% | 6.0% | ×3 — весомо |
При меньшем пуле участников каждый билет имеет бо́льший вес, и разница между 1 и 3 билетами ощутима.
Рекомендации по настройке:
| Приз | BASE | Обоснование |
|---|---|---|
| $1-10 (дешёвый скин) | 250-350 SP | Дефицит, но доступно за 1 цикл |
| $10-30 (средний скин) | 400-600 SP | Требует накопления, высокая ценность билета |
| $30+ (дорогой скин) | 700-1000 SP | Элитный розыгрыш, только для dedicated игроков |
Изменение 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
| Компонент | Путь | Описание |
|---|---|---|
| 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 |
| RaffleLifecycleService | backend/src/domains/streaks/services/raffle-lifecycle.service.ts | Rollover/extend логика розыгрышей |
| RaffleDrawService | backend/src/domains/streaks/services/raffle-draw.service.ts | Логика проведения розыгрыша |
| RaffleAdminService | backend/src/domains/streaks/services/raffle-admin.service.ts | Административные операции розыгрышей |
| RafflePrizePoolService | backend/src/domains/streaks/services/raffle-prize-pool.service.ts | Управление пулом призов |
| RaffleNotificationService | backend/src/domains/streaks/services/raffle-notification.service.ts | Telegram уведомления участникам |
| RaffleUtils | backend/src/domains/streaks/services/raffle-utils.ts | Вспомогательные функции для розыгрышей |
| ConsolationDistributionService | backend/src/domains/streaks/services/consolation-distribution.service.ts | Tier-based распределение утешительных призов |
| ConsolationTierService | backend/src/domains/streaks/services/consolation-tier.service.ts | CRUD тиров, валют, предметов + buildFrozenConfig |
| ConsolationClaimService | backend/src/domains/streaks/services/consolation-claim.service.ts | Claim flow + preview (из frozenConfig) |
| StreakRepository | backend/src/domains/streaks/repositories/streak.repository.ts | Репозиторий стриков |
| StreakPointsRepository | backend/src/domains/streaks/repositories/streak-points.repository.ts | Репозиторий транзакций SP |
| RaffleRepository | backend/src/domains/streaks/repositories/raffle.repository.ts | Репозиторий розыгрышей |
| RafflePrizePoolRepository | backend/src/domains/streaks/repositories/raffle-prize-pool.repository.ts | Репозиторий пула призов |
| DecayJob | backend/src/domains/streaks/jobs/decay.job.ts | Ежедневное списание при неактивности |
| RaffleDrawJob | backend/src/domains/streaks/jobs/raffle-draw.job.ts | Автоматический розыгрыш |
| DailyRewardsJob | backend/src/domains/streaks/jobs/daily-rewards.job.ts | Ежедневный сбор статистики наград |
| BotInventoryCacheService | backend/src/domains/steam-trade-bot/services/bot-inventory-cache.service.ts | Кэш инвентаря Steam бота |
| User Routes | backend/src/domains/streaks/routes/user-streaks.routes.ts | User API |
| Raffle Routes | backend/src/domains/streaks/routes/user-raffles.routes.ts | Raffle User API |
| Admin Routes | backend/src/domains/streaks/routes/admin-raffles.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, 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 | История транзакций SP | userId, amount, balance, type (включая RAFFLE_CONSOLATION), description |
| UserActiveBuff | Streak Shield хранится здесь | buffType: STREAK_SHIELD, usesLeft |
Enums (consolation-related):
ConsolationCurrencyType: SCRAP, XP, SPInventorySourceType: включаетRAFFLE_CONSOLATIONStreakPointsTransactionType: включает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
- 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 поток событий | → |
| GET | /api/raffle/unclaimed-consolation | Незабранные утешительные призы | — |
| POST | /api/raffle/claim-consolation | Забрать все утешительные (batch, без body) | — |
| GET | /api/raffle/:raffleId/consolation-preview | Тир-лист утешительных призов | — |
| Метод | Эндпоинт | Описание | 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 | Обновить кэш | → |
| GET | /admin/settings/raffle | Настройки рафла (6 параметров: duration, participants, pricing, limits) | → |
| PUT | /admin/settings/raffle | Обновить настройки рафла (все поля опциональны) | → |
| GET | /admin/raffle/consolation/tiers | Список тиров | — |
| POST | /admin/raffle/consolation/tiers | Добавить тир (minSP, allocation) | — |
| PUT | /admin/raffle/consolation/tiers/:id | Обновить тир | — |
| DELETE | /admin/raffle/consolation/tiers/:id | Удалить тир | — |
| GET | /admin/raffle/consolation/currencies | Список валют | — |
| POST | /admin/raffle/consolation/currencies | Добавить валюту (type, amountPerTicket, minTierSP) | — |
| PUT | /admin/raffle/consolation/currencies/:id | Обновить валюту | — |
| DELETE | /admin/raffle/consolation/currencies/:id | Удалить валюту | — |
| GET | /admin/raffle/consolation/items | Список предметов | — |
| POST | /admin/raffle/consolation/items | Добавить предмет (itemId, multiplier, minSP) | — |
| PUT | /admin/raffle/consolation/items/:id | Обновить предмет | — |
| DELETE | /admin/raffle/consolation/items/:id | Удалить предмет | — |
| GET | /admin/raffle/consolation/config | Полная конфигурация (tiers + currencies + items) | — |
| POST | /admin/raffle/consolation/validate | Валидация allocation (sum = 1.0) | — |
| GET | /admin/settings/consolation | Настройки утешительных (enabled) | — |
| PUT | /admin/settings/consolation | Обновить настройки утешительных | — |
7. Related
- Daily Spins — Streak Spin открывается за Streak Points
- Cases — Streak Cases открываются за Streak Points
- Buffs — Streak Shield как тип баффа
- Steam Trade Bot — Интеграция для проверки инвентаря и отправки призов
- Quests — Квесты с условием
streakDaysдля достижения стрика