Skip to main content

Seasons

1. Summary

Goal: Временные соревновательные периоды (3 месяца) с рейтингом по XP. Контролирует жизненный цикл игровой экономики: обнуление прогресса, награды для топ-10, обратный отсчёт до старта нового сезона. Периодический сброс предотвращает переизбыток накопленных предметов и контролирует объём выводов скинов.

User Value: Соревновательная мотивация. Попадание в топ-10 гарантирует реальный скин в Steam — уникальная награда за активность без денежных вложений. Игроки вне топ-10 не получают сезонных наград — это элитарная система.


2. Business Logic

Season Lifecycle

4-state machine: DRAFT → SCHEDULED → ACTIVE → COMPLETED

Период: Настройка контента через Setup Wizard (10 шагов)

Доступные действия:

  • Настройка названия, дат, наград
  • Привязка кейсов, рулеток
  • Генерация квизов, квестов, достижений
  • Клонирование контента из другого сезона

Переход в SCHEDULED: При утверждении через Approve (Step 10)

Reward Tiers

ПозицияTierТипичная награда
#1legendaryСамый ценный скин сезона
#2-3mythicalСкин высокой ценности
#4-10epicСкин средней ценности
#11+Без награды
Денормализация наград

imageUrl и itemTier сохраняются в JSON rewards при создании сезона для быстрого отображения без JOIN на таблицу Item.

XP Sources (детализация)

Все источники XP учитываются в UserSeasonStats:

ИсточникПолеОписание
QuestsxpFromTasksВыполнение квестов
AchievementsxpFromAchievementsРазблокировка достижений
CasesxpFromCasesОткрытие кейсов
SpinsxpFromSpinsDaily Spins
ReferralsxpFromReferralsПриглашение друзей
SalvagexpFromSalvageРазбор предметов
AdminxpFromAdminРучное начисление
RafflexpFromRaffleУчастие в раффлах
Promo CodesxpFromPromoCodesАктивация промокодов
BoostsxpFromBoostsНаграды Boost Pass

Scrap Sources (детализация)

Все 11 источников скрапа учитываются в UserSeasonStats (10 через addScrap() + admin-грант через отдельный путь):

ИсточникПолеОписание
QuizzesscrapFromQuizzesПравильные ответы в квизах
QuestsscrapFromTasksВыполнение квестов
AchievementsscrapFromAchievementsРазблокировка достижений
CasesscrapFromCasesОткрытие кейсов
SpinsscrapFromSpinsDaily Spins
ReferralsscrapFromReferralsРеферальный доход
AdminscrapFromAdminРучное начисление
Promo CodesscrapFromPromoCodesАктивация промокодов
RafflescrapFromRaffleВыигрыш в розыгрышах
ConsolationscrapFromConsolationУтешительные призы
BoostsscrapFromBoostsНаграды Boost Pass
Полнота маппинга

SCRAP_SOURCE_TO_SEASON — полный Record (не Partial). Каждый ScrapSource гарантированно обновляет UserSeasonStats. Admin grants проходят через updateSeasonStats() в interactive transaction.

Rank Update Mechanics

Обновление рангов: Каждые 10 минут cron-job RankUpdateJob

Алгоритм:

ROW_NUMBER() OVER (ORDER BY xp DESC) AS rank

Top-10 Threshold: Минимальный XP для попадания в топ-10, отображается пользователю как distanceToTop10.

Season Reset

При завершении сезона сбрасываются:

User поля:

  • scrap, level, xp, все streaks, counters активности

Прогресс:

  • UserInventory (кроме SEASON_REWARD и SKIN items)
  • QuizResult (история ответов)
  • UserQuest, UserAchievement (прогресс сбрасывается)

Экономика сезона:

  • LuckPoolEntry, BudgetPeriod (для старого сезона)

Технические данные:

  • RustPlayerSession, UserActiveBuff, BuffEvent

История операций (развлекательные данные):

  • CaseOpening — история открытий кейсов
  • SpinResult — история спинов рулетки
  • FeedEvent — публичная лента активности
Почему удаляем историю?

Развлекательные данные (~7M записей/сезон при 10k пользователей) не нужны для аудита. Очистка при смене сезона предотвращает неконтролируемый рост БД.

Сохраняются (аудит):

  • friendsInvited (lifetime stat)
  • SEASON_REWARD items (награды прошлых сезонов)
  • SKIN items (скрафченные скины для вывода)
  • CraftHistory — история крафта (потрачены ресурсы)
  • Withdrawal — история выводов (Steam трейды, юридически важно)
  • PromoCodeRedemption — история промокодов
  • RaffleTicket, Raffle — история розыгрышей (выигрыши, споры)
  • AdminGrant — история админских действий

Season Setup Wizard

Единый wizard для настройки и редактирования сезонов через админ-панель:

Setup Mode (DRAFT): 10 шагов с последовательным прохождением и валидацией.

Edit Mode (SCHEDULED/ACTIVE): 9 шагов со свободной навигацией, per-step save.

ШагНазваниеОписание
1Basic InfoНазвание, описание, даты, время старта (HH:MM UTC)
2BudgetКонфигурация бюджета сезона (periods, total, carryover). В ACTIVE — операционный Budget Control
3RewardsНаграды для топ-1, топ-3, топ-10 (скины) — сезонный XP лидерборд
4CasesПривязка кейсов к сезону
5SpinsПривязка рулеток к сезону
6Referral RewardsНаграды для топ-1, топ-3, топ-10 рефереров (скины) — реферальный лидерборд
7QuizzesМассовая генерация, точечная генерация или импорт квизов
8QuestsSmart Quest Generation из блюпринтов
9AchievementsSmart Achievement Generation из блюпринтов
10ApprovalУтверждение и планирование старта (только Setup Mode)
Budget Step (Step 2) — адаптивный по статусу сезона

Шаг адаптируется в зависимости от статуса сезона:

  • DRAFT/SCHEDULED → Конфигурация: количество периодов (1-30), общий бюджет (опционально), переключатель carryover + превью
  • ACTIVE → Config (readonly) + полный операционный Budget Control (статистика, increment/decrement, lock, операции)
  • COMPLETED → Всё readonly

isStepCompleted всегда возвращает true — шаг нельзя заблокировать (defaults валидны).

Referral Rewards (Step 6) — опциональный шаг

isStepCompleted всегда возвращает true для REFERRAL_REWARDS — шаг нельзя заблокировать.

Статусы шага:

  • IN_PROGRESS — находимся на шаге прямо сейчас
  • COMPLETEDreferralRewards !== null (награды настроены)
  • WARNINGreferralRewards === null && setupStep > 6 (явный пропуск через кнопку "Без наград")

Кнопка "Без наград" (только в Setup Mode): вызывает DELETE /referral-rewardsreferralRewards = null → статус шага становится WARNING → лидерборд будет работать без предметных наград.

canApprove (Step 10) принимает WARNING как эквивалент COMPLETED — сезон можно утвердить без настройки referral наград.

Если referralRewards не настроены (null), реферальный лидерборд скрыт в UI. Добавить награды можно в любой момент через Edit Mode.

Навигация в Edit Mode

В Edit Mode (SCHEDULED/ACTIVE) навигация между шагами свободная — можно переходить к любому шагу без валидации предыдущих. Шаг Approve отсутствует.

Persistent ValidationBanner

На каждом шаге визарда отображается persistent ValidationBanner — баннер здоровья сезона, который заменил прежнюю ephemeral toast-валидацию при создании/редактировании квестов. Баннер показывает:

  • Ошибки (красный), предупреждения (жёлтый) или успешный статус (зелёный)
  • Топ-3 проблемы, отсортированные по severity (ошибки первыми)
  • Валидация запрашивается через GET /admin/season-setup/:seasonId/validate с staleTime: 30s

Компонент: admin/src/components/season-setup/ValidationBanner.tsx

Clone Season (Quick Start)

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

Что клонируется:

  • Rewards (top-1, top-3, top-10)
  • Cases (привязки SeasonCase)
  • Spins (привязки SeasonSpin)
  • Quizzes (привязки Quiz → новый сезон)
  • Quests (привязки SeasonQuest)
  • Achievements (привязки SeasonAchievement)

Endpoint: POST /admin/season-setup/:seasonId/clone-from

UI

В шаге Basic Info (Step 1) секция "Быстрый старт" позволяет выбрать source-сезон из dropdown и клонировать одним нажатием.

Smart Quest Generation (Step 8)

Квесты генерируются автоматически из code-defined блюпринтов (38 шт.) с учётом контента сезона.

Алгоритм (5 фаз):

  1. Analyze Content — собрать cases, spins, items (с deduplication по dropChance), scrap analysis, quiz categories/subcategories/slugs/entityTypes
  2. Calculate Pool Sizes — DAILY/WEEKLY/PERMANENT целевые размеры (с 30% буфером)
  3. Instantiate Blueprints — создать кандидатов из блюпринтов × контент (content-aware: COLLECTION блюпринты проверяют наличие соответствующих предметов/скрапа)
  4. Balance Distribution — обрезать избыток, приоритизируя сложные квесты
  5. Save to Database — транзакция: удалить старые auto-generated + создать новые (manual квесты сохраняются при регенерации)

Генерация по режимам (3 режима):

РежимВариантыПримеры
genericДо 3 (DAILY/WEEKLY), 1 (PERMANENT)"Охотник за удачей I/II/III"
per-case1 на каждый кейс сезона"Фанат «Discharge»"
per-item1 на high-tier предмет (max 3)"За «AK-47»!"
Quiz-specific режимы -- только в Achievement blueprints

Специфичные quiz-режимы (per-quiz-category, per-quiz-subcategory, per-quiz-slug, per-quiz-entitytype) реализованы как Achievement blueprints, а не Quest blueprints. Причина: публикация квизов (3/день через Telegram, случайная выборка) не координируется с ротацией квестов, поэтому специфичные quiz-квесты часто были бы невыполнимы в рамках своего временного окна. Достижения -- долгосрочные цели, и эта проблема для них не актуальна.

Content-Aware COLLECTION: Генератор анализирует реальный контент сезона — предметы и скрап из кейсов и рулеток. COLLECTION блюпринты (12 шт.) генерируют квесты только если соответствующий контент существует (ресурсы, фрагменты, чертежи, скрап). Скрап-квесты динамически капируются на основе среднего дропа.

Quiz Quests = Generic Only

Quiz-квесты используют только generic режим с фиксированными таргетами (daily=1, weekly=5, permanent=15). Специфичные quiz-задания будут реализованы как Achievement templates. Подробнее: Quests ADR

Полная матрица блюпринтов: Quest Blueprints

Placeholder rewards

Генерируемые квесты получают placeholder SCRAP награды (Easy=50, Medium=100, Hard=150). Админ настраивает финальные награды после генерации.

Manual Quest Creation

Помимо автогенерации, админ может создать квест вручную прямо из визарда:

  • Кнопка «+» в заголовке каждой категории — создаёт квест с предзаполненной категорией
  • Кнопка «Создать квест» — создаёт без привязки к категории
  • Two-step flow: форма создания → автоматическая привязка к сезону
  • После создания запускается фоновая валидация сезона
  • Кнопки скрыты для завершённых сезонов (COMPLETED)
Quest Deletion = Hard Delete

Удаление квеста из сезона (через «Clear quests» или поштучно) всегда выполняет hard delete — квест, награда, связанные UserQuest, ClaimedUniqueQuest и UserQuestExclusion полностью удаляются из БД. Это касается всех квестов — и auto-generated, и manual/imported. Квесты существуют только в контексте сезона.

Исключение: регенерация (Save to Database) удаляет только auto-generated квесты и сохраняет manual.

Achievement Generation (Step 9)

Достижения генерируются автоматически из шаблонов (blueprints).

Manual Achievement Creation

Аналогично квестам, админ может создать достижение вручную:

  • Кнопка «+» в заголовке каждой категории — создаёт с предзаполненной категорией
  • Кнопка «Создать достижение» — создаёт без привязки к категории
  • Two-step flow: форма создания → привязка к сезону через POST /:seasonId/achievements/add
  • Кнопки скрыты для завершённых сезонов (COMPLETED)

Quiz Generation (Step 7)

Квизы генерируются автоматически из шаблонов + SQLite базы данных Rust. Доступны два подхода: массовая генерация по категориям и точечная генерация по конкретным предметам.

Массовая генерация (Primary)

Входные данные:

  • categories — массив категорий: weapons, food, raid, attire
  • total — общее количество квизов (по умолчанию 270)
  • mode — режим генерации: regenerate (по умолчанию) или append

Режимы генерации:

РежимПоведениеБюджетUsed counters
regenerateУдаляет все существующие квизы, создаёт новыеSET (абсолютное значение)Сброс до 0
appendДобавляет новые к существующим без удаленияINCREMENT (прибавление)Без изменений
Догенерация (append)

Append-режим исключает дубли: перед генерацией собираются вопросы существующих квизов сезона, и новые генерируются только из оставшегося пула. Подтверждение не требуется — операция безопасна.

Алгоритм распределения:

  1. total / categories.length квизов на каждую категорию
  2. Внутри категории: 50% EASY, 30% MEDIUM, 20% HARD
  3. Для каждого слота: случайный предмет из SQLite × случайный шаблон

Точечная генерация (Targeted)

Позволяет генерировать квизы для конкретных предметов (shortnames) вместо целых категорий. Используется когда нужно добавить квизы для отдельных предметов — например, при создании slug-targeted достижений.

UI: Collapsible-секция «Точечная генерация» в Step 7 Wizard.

Flow:

  1. Админ выбирает категорию → загружаются предметы с количеством доступных шаблонов
  2. Опциональная фильтрация по подкатегории и поиску по имени
  3. Выбор конкретных предметов (чекбоксы, Select All / Deselect All)
  4. Опциональное ограничение количества (по умолчанию = максимум из шаблонов)
  5. Генерация → результат: +N квизов (Easy: X, Medium: Y, Hard: Z) + breakdown по предметам

Входные данные:

  • category — одна категория (например weapons)
  • shortnames — массив shortname-ов предметов (например ["rifle.ak", "smg.mp5"])
  • count — опциональный лимит (по умолчанию = все возможные шаблоны)

Особенности:

  • Всегда append-режим — добавляет квизы без удаления существующих
  • Исключает дубли по question текста (как и массовая генерация)
  • Бюджет SeasonContentBudget инкрементируется атомарно в транзакции

Общее

Источники данных:

  • backend/data/quiz-templates.json — шаблоны вопросов по категориям
  • backend/data/rust_data.db — SQLite база с данными Rust (предметы, рецепты, стоимости)

Архитектура "Fresh + Persist": Каждый сезон получает свежие квизы. После завершения сезона квизы остаются для аналитики (currentSeasonId = null, isActive = false).

Производительность

Все квизы записываются в БД одним createMany INSERT вместо индивидуальных создаёт.

Activity Counters (глобальные счётчики)

Публичный виджет на экранах Кейсов и Рулеток, показывающий общее количество открытий/прокрутов за текущий сезон всеми пользователями.

Формулы:

  • Кейсы: SUM(casesOpened) по всем UserSeasonStats текущего сезона
  • Рулетки: SUM(dailySpinsUsed) по всем UserSeasonStats текущего сезона
  • ratePerHour = total / max(1, hoursElapsedSinceSeasonStart) — средняя скорость за сезон

Кеширование: Redis, key goloot:v1:season:{seasonId}:activity-counters, TTL 60s (в Repository layer).

Frontend (live counter):

  • Polling каждые 60s (совпадает с Redis TTL)
  • useLiveCounter hook интерполирует между поллами: тикает +1 с интервалом 3600s / ratePerHour
  • Optimistic +1 при открытии кейса/спина пользователем (через window.dispatchEvent)
  • Math.max(displayValue, serverValue) при синке — счётчик никогда не уменьшается
  • OdometerCounter: flip-clock стиль, 8 цифр с leading zeros (opacity-30), translateY в em-юнитах

Protection

ДействиеRate LimitAuthValidation
Получить текущий сезонgeneral (100/min)TelegramGetCurrentSeasonSchema
Получить статистикуgeneral (100/min)TelegramGetSeasonStatsSchema
Получить leaderboardgeneral (100/min)TelegramGetLeaderboardSchema
Получить activity countersgeneral (100/min)Нет (публичный)GetActivityCountersSchema
Завершить сезон (admin)mutations (5/min)Admin JWT + парольAdminForceEndSeasonSchema
Запустить сезон (admin)mutations (5/min)Admin JWT + парольAdminStartSeasonSchema
Детали реализации

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

Display Status (Frontend States)

Backend возвращает displayStatus — вычисляемое состояние, определяющее что показывать пользователю:

displayStatusКогдаUIОперации
activeACTIVE сезон, > INTER_SEASON_MINUTES до концаОбычный интерфейс✅ Все доступны
endingACTIVE сезон, ≤ INTER_SEASON_MINUTES до концаOverlay с обратным отсчётом MM:SS❌ Заблокированы
interSeasonНет ACTIVE, но есть SCHEDULEDOverlay: результаты прошлого сезона + countdown до следующего❌ Заблокированы
noSeasonНет ни ACTIVE, ни SCHEDULEDOverlay: результаты последнего сезона (если был)❌ Заблокированы
displayStatus — computed, not stored

displayStatus вычисляется в SeasonService.getSeasonInfo() на основе текущего состояния БД и времени. Не хранится в модели Season.

Season Timer Service

Точное планирование активации сезонов через setTimeout:

МетодОписание
scheduleActivation(seasonId, startDate)Установить таймер на конкретную дату
cancelActivation(seasonId)Отменить запланированную активацию
rescheduleAllOnBoot()При перезагрузке: восстановить таймеры для всех SCHEDULED сезонов
getActiveTimers()Мониторинг: список ID сезонов с активными таймерами

Обработка ограничения setTimeout:

  • JavaScript setTimeout ограничен ~24.8 днями (2^31 - 1 ms)
  • Для более далёких дат: рекурсивное перепланирование через MAX_TIMEOUT_MS
  • Если startDate уже в прошлом → немедленная активация

Интеграция: При Approve (Step 10) wizard устанавливает scheduledAt с датой и временем (HH:MM UTC). SeasonTimerService создаёт таймер. При ручном старте или удалении — таймер отменяется.

Season Status Middleware

Middleware requireActiveSeason блокирует операции при отсутствии или завершении сезона:

ОшибкаHTTPУсловиеPayload
NO_ACTIVE_SEASON503Нет сезона со status=ACTIVE{ error, message }
SEASON_ENDING503ACTIVE, но ≤ INTER_SEASON_MINUTES до endDate{ error, message, countdown: { totalSeconds } }

Применяется к: Операции с экономикой (открытие кейсов, спины, salvage, крафт). Не применяется к: Чтение данных (сезон, статистика, leaderboard).

// Использование в routes
fastify.post('/api/cases/open', {
preHandler: [authMiddleware, requireActiveSeason(prisma)]
}, handler);

Inter-Season Overlay (Frontend)

SeasonCountdownOverlay — fullscreen overlay, управляемый через useSeason() hook:

РежимКонтентОбновление
endingCountdown MM:SS до конца сезонаPolling каждые 30 сек
interSeasonРезультаты прошлого сезона + countdown DD:HH:MM:SS до следующегоPolling каждые 30 сек
noSeasonРезультаты последнего сезона (если был)Polling каждые 30 сек
activeOverlay скрытPolling отключён

useSeason() hook возвращает:

  • displayStatus — текущее состояние
  • season — DTO активного сезона (для active/ending)
  • lastSeason — результаты прошлого сезона (для interSeason/noSeason)
  • nextSeason — инфо о следующем сезоне (для ending/interSeason)
  • countdown — секунды до перехода
  • isOverlayVisibledisplayStatus !== 'active'
  • isActivedisplayStatus === 'active'

Frontend Integration (LoadingOrchestrator)

Early System Check — проверка maintenance и доступности сезона выполняется параллельно (ШАГ 0) в LoadingOrchestrator.initialize() через Promise.all([checkMaintenanceStatus(), checkSeasonStatus()]), до загрузки Bootstrap.

Зачем: Без активного сезона пользователь не может взаимодействовать с приложением (все критичные endpoints защищены requireActiveSeason middleware). Early check предотвращает бесполезную загрузку Bootstrap (~8 запросов к БД), показывает SeasonCountdownOverlay сразу.

Как работает:

  1. LoadingOrchestrator устанавливает loadingState = 'checking_maintenance' и запускает Promise.all для параллельной проверки maintenance и сезона
  2. Выполняется запрос к /api/seasons/current (требует telegramAuth — заголовок X-Telegram-Init-Data)
  3. Если displayStatus !== 'active' → гидрируется React Query cache, показывается SeasonCountdownOverlay, инициализация останавливается
  4. Если displayStatus === 'active' → продолжается дальнейшая загрузка (onboarding, bootstrap)

React Query Cache Hydration:

// Если сезон не активен — сохраняем данные в cache
if (seasonStatus.displayStatus !== 'active') {
if (seasonStatus.data) {
queryClient.setQueryData(['season'], seasonStatus.data);
}
// SeasonCountdownOverlay может сразу использовать эти данные
// без дополнительного запроса через useSeason()
}

Graceful Degradation:

try {
const status = await checkSeasonStatus();
if (status.displayStatus !== 'active') {
// Блокируем Bootstrap
return;
}
} catch (error) {
// При ошибке НЕ блокируем пользователя
console.warn('Season check failed, continuing');
// Возвращаем безопасный default: displayStatus = 'active'
// Продолжаем к следующему шагу
}
Принцип Graceful Degradation

Если проверка сезона упала с ошибкой (нет интернета, 500 от backend), пользователь не блокируется. Предполагается что сезон активен, и пользователь может продолжить. Это предотвращает ситуацию, когда баг в проверке сезона блокирует всех пользователей.

Файлы:

  • frontend/src/components/LoadingOrchestrator.tsx — функция checkSeasonStatus(), вызов в initialize(), cache hydration
  • frontend/src/types/loading.types.tschecking_season loading state (определён в типах, но в runtime устанавливается checking_maintenance перед Promise.all)
  • frontend/src/hooks/useSeason.ts — query key ['season'] для cache

Edge Cases

СитуацияUI поведение
Нет активного сезона, есть SCHEDULEDOverlay interSeason с countdown до startDate
Нет ни ACTIVE, ни SCHEDULEDOverlay noSeason с результатами последнего сезона
Пользователь не в топ-10Показ distanceToTop10 — сколько XP нужно
Сезон только завершилсяOverlay interSeason с finalResults победителей
LoadingOrchestrator при неактивном сезонеEarly check (ШАГ 0) блокирует Bootstrap → экономия ~8 DB запросов
Ошибка раздачи наградЛогирование, retry не реализован (ручное исправление)
Пользователь в топ-10 XP, рефереров и/или бустеровСоздаются отдельные SeasonRewardClaim для каждого типа (XP, REFERRAL, BOOST_LEADERBOARD) — все вознаграждения выдаются независимо
Admin scrap grant без season statsНевозможно — addScrap использует interactive transaction с updateSeasonStats(). Все 10 scrap sources через addScrap() гарантированно обновляют USS; admin-грант проходит через updateSeasonStats() напрямую
Перезагрузка сервераrescheduleAllOnBoot() восстанавливает таймеры SCHEDULED сезонов
Referral награды добавлены в уже активный сезонReferralRewardsSeasonModal показывается пользователю один раз (localStorage referral_rewards_seen_{seasonId}). Хук: useReferralRewardsNotification, компонент: ReferralRewardsSeasonModal.tsx
Шаг 6 пропущен через "Без наград"Статус шага = WARNING в Stepper (жёлтый ⚠). Баннер в Step 6 и Step 10 (Approval) информирует что лидерборд работает без наград
setTimeout > 24.8 днейРекурсивное перепланирование через MAX_TIMEOUT_MS
Activity counter: нет активного сезонаSeasonCounterBanner возвращает null — баннер не рендерится
Activity counter: optimistic +1 обгоняет серверMath.max(displayValue, serverValue) — число никогда не уменьшается, сервер подтянет при следующем poll

Admin Notifications

Уведомления для админов через Telegram при событиях сезона (топик SEASONS):

СобытиеТипКогда
Сезон стартовалSEASON_STARTEDПри активации (timer или ручной старт)
Сезон завершёнSEASON_ENDEDПосле автоматического/ручного завершения
Нужна настройкаSEASON_NEEDS_SETUPНет следующего SCHEDULED сезона после завершения
Скоро конецSEASON_ENDING_SOONЗа 7, 3, 1 день до endDate

Все события содержат seasonId для прямых ссылок на админ-панель.

Конфигурация
TELEGRAM_GROUP_ID=<group_id>  # ID группы для уведомлений (используется TopicsService)
ADMIN_URL=https://admin.goloot.online # URL для ссылок в уведомлениях

При отсутствии TELEGRAM_GROUP_ID сервис работает в silent mode (логирует, не падает).

WARNING_DAYS: [7, 3, 1] — за сколько дней отправлять предупреждения. Cron каждый день в 10:00.


3. ADR (Architectural Decisions)

Почему Serializable транзакции для смены сезона?

Проблема: Смена сезона — критическая операция. Race condition между деактивацией старого и созданием нового сезона может привести к 0 или 2 активным сезонам.

Решение: Serializable transaction в atomicSeasonTransition:

  1. Проверка что сезон не изменился за время подготовки
  2. Деактивация старого + создание нового в одной транзакции
  3. Isolation level Serializable предотвращает phantom reads

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

  • Distributed lock (Redis) — усложнение инфраструктуры
  • Optimistic locking — не гарантирует isolation

Последствия: Высокая надёжность, но блокировка таблицы Season на время транзакции (~100-500ms).

Почему lifecycle job обходит Redis-кэш (defense in depth)?

Проблема: Cache poisoning инцидент 16.03.2026. RustQuestProgressService.isSeasonActive() записывал частичный объект (select: { id: true }) в общий кэш-ключ activeSeason. Lifecycle cron читал этот объект, endDate был undefined, now < undefined → false — сезон завершился на 2.5 месяца раньше.

Решение (3 уровня):

  1. Корень: Rust сервис переведён на SeasonRepository.getActiveSeason() (полный объект)
  2. Архитектура: Lifecycle job использует getActiveSeasonDirect() — прямой DB-запрос, кэш полностью обходится. Стоимость: 1 запрос каждые 5 мин (ничтожно)
  3. Safety net: MIN_ACTIVE_DAYS_FOR_AUTO_TRANSITION = 7 — cron автоматически блокирует transition для сезонов активных менее 7 дней. Админский force-end не затронут

Принцип: Необратимые операции (transition уничтожает данные) никогда не должны зависеть от кэша. Defense in depth: даже при неизвестном баге safety net предотвращает катастрофу.

Почему длительность сезона 3 месяца?

Проблема: Без сброса игроки накапливают слишком много предметов, что приводит к массовым выводам и нагрузке на экономику.

Решение: 3-месячный цикл с полным сбросом:

  • Контроль объёма накопленных предметов
  • Предсказуемые пики вывода (конец сезона)
  • Справедливый старт для всех

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

  • 1 месяц — слишком короткий, нет времени для гонки
  • 6 месяцев — переизбыток предметов, снижение ценности

Последствия: Регулярные сбросы, но управляемая экономика.

Почему inter-season overlay вместо COUNTDOWN статуса?

Проблема: Исходно был отдельный статус COUNTDOWN в enum, что создавало:

  • Дублирование isActive + status (риск рассинхронизации)
  • 5-state machine вместо 4 (переусложнение)
  • UI не мог однозначно определить что показывать

Решение: Удалить COUNTDOWN из enum, вычислять displayStatus на лету:

  • ending = ACTIVE сезон, но ≤ 10 мин до конца
  • interSeason = нет ACTIVE, но есть SCHEDULED (показ результатов + countdown)
  • noSeason = нет ни ACTIVE, ни SCHEDULED

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

  • Хранить displayStatus в БД — двойной source of truth
  • Оставить COUNTDOWN — лишнее состояние, дублирование с isActive

Последствия: Чистый 4-state machine. UI-состояния определяются временем, а не хранятся.

Почему SeasonTimerService (setTimeout) вместо cron для активации?

Проблема: Cron с 5-минутным интервалом не даёт точности запуска. Если сезон должен стартовать в 18:00 — cron активирует его в промежутке 18:00-18:04.

Решение: SeasonTimerService с точным setTimeout на startDate:

  • Точность до миллисекунды
  • При перезагрузке: rescheduleAllOnBoot() восстанавливает таймеры
  • Обход лимита setTimeout (24.8 дня) через рекурсивное перепланирование

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

  • Cron каждую минуту — избыточная нагрузка, всё равно ~1 мин задержки
  • Внешний scheduler (Bull/BullMQ) — лишняя зависимость для одной задачи

Последствия: Точный старт, но таймеры живут в памяти процесса. При crash — восстановление при следующем boot.

Почему денормализация imageUrl в rewards JSON?

Проблема: При каждом запросе сезона нужно обогащать награды данными из Item (imageUrl, tier).

Решение: Денормализация при сохранении через prepareRewardsForSave():

  • imageUrl и itemTier сохраняются в JSON rewards
  • Single Source of Truth остаётся в Item table
  • Обновление при изменении сезона

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

  • Runtime enrichment — лишний JOIN на каждый запрос
  • Полное копирование Item данных — избыточно

Последствия: Быстрые чтения, но при изменении Item нужно помнить обновить сезоны.

Почему валидация наград через Steam бота?

Проблема: Админ может выбрать скин для награды, которого нет на Steam боте → победитель не получит приз.

Решение: Двухуровневая валидация в validateRewards():

  1. Проверка существования Item в БД с itemType = 'SKIN'
  2. Проверка наличия скина в инвентаре Steam бота через check-reward-availability

Особенности:

  • Один скин можно использовать для нескольких мест (top-1, top-3, top-10)
  • Graceful degradation при недоступности Steam API

Последствия: Гарантия выполнимости награды, но зависимость от Steam API.

Почему 3-шаговая защита завершения сезона?

Проблема: Случайное завершение сезона необратимо — сброс прогресса тысяч пользователей.

Решение: Multi-step confirmation flow:

  1. Предупреждение — список последствий (награды, сброс, новый сезон)
  2. Ввод текста — точное совпадение "ЗАВЕРШИТЬ СЕЗОН N" (case-sensitive)
  3. Пароль админа — финальное подтверждение через AuthService.validateCredentials()

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

  • Одна кнопка с confirm — слишком просто для критической операции
  • 2FA — усложняет инфраструктуру

Последствия: Защита от случайных кликов, но медленнее для намеренного завершения.

Почему hard delete всех квестов при удалении из сезона?

Проблема: При удалении квестов через wizard (кнопка «Clear quests» или поштучное удаление) manual/imported квесты лишь отвязывались от сезона (SeasonQuest удалялся), но сам Quest оставался в БД. Это приводило к накоплению orphaned квестов в админ-реестре без возможности их очистки.

Решение: Hard delete для всех квестов при удалении из сезона. Порядок в транзакции:

  1. UserQuestExclusion — нет CASCADE на FK, удалять вручную
  2. SeasonQuest — связь сезон↔квест
  3. Quest — CASCADE автоматически удаляет UserQuest, ClaimedUniqueQuest
  4. Reward — нет CASCADE от Quest, удалять вручную

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

  • Soft delete (deletedAt) — квесты всё равно засоряют реестр, нужен отдельный cleanup
  • Hard delete только auto-generated — manual квесты становятся orphaned

Исключение: При регенерации (generateQuests) удаляются только auto-generated квесты — manual квесты сохраняются, чтобы не потерять ручную работу админа.

Последствия: Чистый реестр квестов, но удаление необратимо (восстановление только через re-import).

Каскадный порядок удаления сезона

Проблема: При удалении сезона необходимо соблюдать порядок удаления связанных данных из-за FK constraints.

Решение: Строгий порядок в транзакции:

  1. UserSeasonStats — статистика пользователей
  2. CraftBudgetLog — логи крафт-бюджета (FK на BudgetPeriod)
  3. BudgetPeriod — периоды бюджета
  4. LuckPoolEntry — записи пула удачи
  5. SeasonRewardClaim — клеймы наград сезона
  6. Season — сам сезон

Критично: CraftBudgetLog должен удаляться до BudgetPeriod из-за craft_budget_logs_periodId_fkey.

Последствия: Надёжное каскадное удаление без FK violations.

Почему SeasonRewardClaim имеет поле type?

Проблема: Исходный unique constraint @@unique([userId, seasonId]) допускал только одну запись на пользователя за сезон. Если пользователь попадал в топ-10 по XP и в топ-10 рефереров — второй upsert (с пустым update: {}) молча проигнорировал бы создание второй записи, и реферальная награда была бы потеряна без каких-либо ошибок.

Решение: Добавить поле type RewardClaimType @default(XP) и изменить unique constraint на @@unique([userId, seasonId, type]):

  • type: "XP" — награда XP leaderboard (топ-10 по XP)
  • type: "REFERRAL" — награда referral leaderboard (топ-10 рефереров)
  • type: "BOOST_LEADERBOARD" — награда boost leaderboard (топ-10 бустеров)

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

  • Отдельная таблица SeasonReferralRewardClaim — дублирование схемы без пользы
  • Хранить оба вознаграждения в одной записи (JSON) — усложняет идемпотентность

Последствия: Пользователь, попавший в несколько топ-10, получает отдельные SeasonRewardClaim для каждого типа. Каждый тип распределяется идемпотентно через upsert с update: {}.

Известное ограничение: частичный сбой при раздаче наград

Проблема: SeasonLifecycleJob использует идемпотентную проверку seasonRewardClaim.count > 0 перед запуском дистрибуции. Если XP-награды были распределены успешно, а referral-распределение завершилось с ошибкой — повторный запуск lifecycle job пропустит оба этапа (check count > 0 будет истинным из-за уже созданных XP-записей).

Текущий статус: Не исправляется в рамках данного изменения. Требует отдельного рефакторинга SeasonLifecycleJob с раздельными флагами идемпотентности для XP, REFERRAL и BOOST_LEADERBOARD распределений.

Последствия: При частичном сбое необходимо ручное вмешательство (удалить XP-записи из SeasonRewardClaim и перезапустить lifecycle job).

Почему scrapTotalEarned переименован в scrapEarned?

Проблема: scrapTotalEarned — некорректное имя в per-season контексте. «Total» подразумевает сумму за всё время, но поле хранит earned за конкретный сезон. Фронтенд для Season использовал scrap (баланс earned−spent), а для All Time — scrapTotalEarned (только earned). Несогласованность приводила к отображению отрицательных значений при тратах скрапа, полученного через admin grant.

Корень: 4 из 10 источников скрапа (admin, promo_code, raffle, consolation) не обновляли UserSeasonStats. Маппинг SCRAP_SOURCE_TO_SEASON был Partial<Record> — для отсутствующих источников updateSeasonScrap() не вызывался. Admin grant вообще обходил scrap.service.ts. В результате: трата скрапа (через spendScrap) декрементила USS.scrap, но доход не инкрементил → баланс уходил в минус.

Решение:

  1. Rename scrapTotalEarnedscrapEarned (убрать confusing «Total»)
  2. Добавить 4 недостающих scrapFrom* поля в схему
  3. Маппинг SCRAP_SOURCE_TO_SEASON → полный Record (не Partial)
  4. Admin addScrap/removeScrap — interactive transaction с updateSeasonStats()
  5. Фронтенд: единое поле scrapEarned для обоих периодов (Season и All Time)

Последствия: Все 10 scrap sources гарантированно обновляют UserSeasonStats. Отрицательные значения в статистике невозможны.

Почему Budget Control перенесён в Season Wizard?

Проблема: Budget Control жил как отдельная страница /budget, показывая данные активного сезона без идентификации какому сезону они принадлежат. Бюджетные параметры (333₽/день, 10-дневные периоды, 9 периодов) были глобальными константами — одинаковыми для всех сезонов.

Решение: Перенести Budget Control в Season Setup Wizard как Step 2. Шаг адаптируется по статусу:

  • DRAFT/SCHEDULED — конфигурация per-season параметров
  • ACTIVE — readonly config + операционный Budget Control
  • COMPLETED — всё readonly

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

  • Оставить отдельной страницей с dropdown сезона — теряется контекст wizard flow
  • Дублировать на обеих страницах — DRY violation

Последствия: Бюджет конфигурируется в контексте конкретного сезона. Standalone /budget страница и sidebar entry полностью удалены.

Почему activity-counters endpoint без авторизации?

Проблема: Глобальные счётчики (всего кейсов открыто, спинов использовано) не привязаны к конкретному пользователю. Добавление Telegram auth увеличивало бы нагрузку на валидацию без пользы.

Решение: GET /api/seasons/activity-counters — публичный endpoint, без telegramAuth middleware. Защита: rateLimitConfigs.general (100/min).

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

  • Telegram auth как у остальных season endpoints — избыточно для агрегатных данных
  • CDN caching — усложнение инфраструктуры, Redis TTL 60s достаточен

Последствия: Минимальная нагрузка, простое кеширование. Единственный публичный endpoint в season routes.

Почему OdometerCounter, а не AnimatedValue?

Проблема: В проекте есть AnimatedValue.tsx + useCountUp.ts — count-up анимация (плавный счёт от A до B одним числом) с shimmer/shrink эффектами и звуком. Нужен другой визуальный эффект для фонового счётчика.

Решение: Отдельный компонент OdometerCounter — каждая цифра анимируется независимо через CSS translateY (как счётчик пробега). Нет shimmer/звука — тихий, фоновый элемент. Flip-clock стиль: каждая цифра в тёмной rounded-ячейке.

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

  • Переиспользование useCountUp с кастомным рендером — разная визуальная механика, костыльная адаптация
  • CSS-only анимация (@keyframes) — нет контроля над per-digit анимацией

Последствия: Два компонента для двух разных паттернов. AnimatedValue — для наград (яркий, со звуком). OdometerCounter — для фоновых счётчиков (тихий, постоянный).


4. Architecture

Services Overview

Cron Jobs

JobScheduleОписание
Season Lifecycle*/5 * * * * (каждые 5 мин)Проверка окончания ACTIVE сезона, 9-step transition
Rank Update*/10 * * * * (каждые 10 мин)Batch update рангов
Pool Update0 0,12 * * * (дважды в день)Luck Pool processing
Period Check5 0 * * * (ежедневно)Budget period transition
Warning0 10 * * * (ежедневно 10:00)Уведомления о скором конце сезона
Активация через SeasonTimerService

Активация SCHEDULED → ACTIVE выполняется не cron-ом, а точным setTimeout через SeasonTimerService. Это обеспечивает запуск сезона ровно в указанное время (HH:MM UTC), а не с задержкой до 5 мин.

Key Components

КомпонентПутьОписание
SeasonServiceseason.service.tsПолучение сезонов, getSeasonInfo() → displayStatus
SeasonTimerServiceseason-timer.service.tssetTimeout-based активация SCHEDULED сезонов
SeasonStatusMiddlewareseason-status.middleware.ts503 при NO_ACTIVE_SEASON / SEASON_ENDING
SeasonStatsServiceseason-stats.service.tsСтатистика пользователя, leaderboard
SeasonRewardServiceseason-reward.service.tsРаздача наград: топ-10 по XP (season.rewards) + топ-10 рефереров (season.referralRewards) + топ-10 бустеров (season.boostLeaderboardRewards)
SeasonResetServiceseason-reset.service.tsСброс прогресса между сезонами
SeasonRepositoryseason.repository.tsDB операции, batch rank update
SeasonLifecycleJobseason-lifecycle.job.tsCron orchestrator
RankUpdateJobrank-update.job.tsПериодическое обновление рангов
AdminNotificationServiceadmin-notification.service.tsTelegram уведомления для админов
User Routesuser-seasons.routes.ts/api/seasons/*
Admin Routesadmin-seasons.routes.ts/admin/seasons/*
Admin Content Budget Routesadmin-content-budget.routes.ts/admin/content-budget/*
SeasonQuizServiceseason-quiz.service.tsГенерация, импорт, валидация квизов сезона
QuizGeneratorServicequiz-generator.service.tsTemplate-based генерация квизов из SQLite данных
SqliteServicesqlite.service.tsRead-only доступ к Rust game data (rust_data.db)
SeasonQuestGeneratorServiceseason-quest-generator.service.tsSmart Quest Generation из блюпринтов
SeasonCloneServiceseason-clone.service.tsКлонирование контента между сезонами (включая boostLeaderboardRewards)
SeasonSetupServiceseason-setup.service.tsУправление шагами Setup Wizard
SeasonCaseServiceseason-case.service.tsПривязка кейсов к сезону
SeasonSpinServiceseason-spin.service.tsПривязка рулеток к сезону
ContentAllocationServiceseason-content-allocation.service.tsФормирование пула контента сезона
ContentValidationServicecontent-validation.service.tsВалидация достижимости контента перед стартом. Сообщения review-валидации показывают прогресс: «проверено N из M»
ExhaustionMonitorServiceexhaustion-monitor.service.tsМониторинг исчерпания контента и автосброс пула
Quest Blueprintsquest-blueprints.ts38 шаблонов генерации квестов (3 CASES, 3 QUIZ, 8 RECYCLE, 10 COLLECTION, 2 SOCIAL, 3 SPECIAL, 3 PROGRESSION, 6 ECONOMY)
AchievementGeneratorServiceachievement-generator.service.tsSmart Achievement Generation (5-phase, blueprint-based)
Achievement Blueprintsachievement-blueprints.ts18 шаблонов генерации достижений (4 QUIZ, 3 CASES, 2 COLLECTION, 2 RECYCLE, 1 SOCIAL, 1 STREAK, 2 ECONOMY, 2 PROGRESSION, 1 SPECIAL)
Admin Setup Routesadmin-season-setup.routes.ts/admin/season-setup/* (wizard)
OdometerCounterOdometerCounter.tsxFlip-clock цифровой счётчик с per-digit CSS анимацией
SeasonCounterBannerSeasonCounterBanner.tsxБаннер глобальной активности на CasesScreen/DailySpinScreen
useLiveCounteruseLiveCounter.tsКлиентская интерполяция: ticking + optimistic increment
useSeasonCountersuseSeasonCounters.tsReact Query hook для activity-counters с 60s polling
ValidationBannerValidationBanner.tsxPersistent баннер валидации на каждом шаге визарда
ReferralRewardsStepReferralRewardsStep.tsxStep 6 визарда: настройка наград реферального лидерборда (top1/top3/top10); переиспользует SeasonRewardsForm
BudgetStepBudgetStep.tsxStep 2 визарда: конфигурация бюджета (DRAFT) / операционный Budget Control (ACTIVE)

5. Database Schema

Models

МодельОписаниеКлючевые поля
SeasonСезонnumber, name, startDate, endDate, status, scheduledAt, actualStartAt, rewards, referralRewards, boostLeaderboardRewards, finalResults, budgetPeriodsCount, budgetTotalRub, budgetCarryoverEnabled
UserSeasonStatsСтатистика за сезонuserId, seasonId, xp, scrap, level, rank, breakdown полей
SeasonRewardClaimПолученные наградыuserId, seasonId, type ("XP"|"REFERRAL"|"BOOST_LEADERBOARD"), position, rewardSnapshot; @@unique([userId, seasonId, type])
SeasonContentBudgetБюджет контента сезона (квизы)seasonId, quizTotalBudget, quizEasy/Medium/HardBudget, quizEasy/Medium/HardUsed, status
SeasonAchievementСвязь достижения с сезономseasonId, achievementId, isAutoGenerated, rewardStatus
SeasonCaseСвязь сезона с кейсомseasonId, caseId, sortOrder, isActive
SeasonSpinСвязь сезона с рулеткойseasonId, spinId, sortOrder, isActive
SeasonQuestСвязь сезона с квестомseasonId, questId, generatedFrom

Relationships

Season Status Enum

enum SeasonStatus {
DRAFT = 'DRAFT', // Админ настраивает контент сезона
SCHEDULED = 'SCHEDULED', // Настройка завершена, ждёт активации
ACTIVE = 'ACTIVE', // Активный сезон
COMPLETED = 'COMPLETED' // Завершён
}

Shared Constants (SEASON_LIFECYCLE)

// shared/src/constants/seasonLifecycle.ts
export const SEASON_LIFECYCLE = {
INTER_SEASON_MINUTES: 10, // Минуты до endDate → "ending" state, операции блокируются
CRON_TRANSITION_INTERVAL: '*/5 * * * *', // Проверка завершения ACTIVE сезона
WARNING_DAYS: [7, 3, 1], // Дни до конца → Telegram уведомления админам
MIN_ACTIVE_DAYS_FOR_AUTO_TRANSITION: 7, // Safety net: минимум дней для авто-завершения
} as const;
UserSeasonStats — полная структура полей

Балансы:

  • scrap, scrapEarned, xp, level

Scrap breakdown:

  • scrapFromQuizzes, scrapFromTasks, scrapFromAchievements
  • scrapFromCases, scrapFromSpins, scrapFromReferrals
  • scrapFromAdmin, scrapFromPromoCodes, scrapFromRaffle, scrapFromConsolation, scrapFromBoosts

Scrap расходы:

  • scrapSpent, scrapSpentOnCases, scrapSpentOnCraft, scrapSpentOnSpins

XP breakdown:

  • xpFromTasks, xpFromAchievements
  • xpFromReferrals, xpFromCases, xpFromSpins
  • xpFromSalvage, xpFromAdmin
  • xpFromRaffle, xpFromPromoCodes, xpFromBoosts

Quiz stats:

  • quizzesCompleted, correctAnswers, incorrectAnswers

Streaks:

  • bestDailyLoginStreak

Streak Points:

  • streakPointsEarned, streakPointsSpent

Activity:

  • casesOpened, dailyCasesOpened, itemsCrafted
  • itemsSalvaged, dailySpinsUsed, tasksCompleted
  • achievementsUnlocked, friendsInvited

Rank:

  • rank — текущая позиция
  • rankUpdAt — когда обновлялось

6. API Endpoints

МетодЭндпоинтОписаниеDocs
GET/api/seasons/currentТекущий сезон + displayStatus (требует Telegram auth)
GET/api/seasons/statsСтатистика пользователя за сезон
GET/api/seasons/leaderboardТоп игроков + позиция пользователя
GET/api/seasons/activity-countersГлобальные счётчики активности (без auth)

  • Cases — источник XP и Scrap
  • Daily Spins — источник XP и Scrap
  • Budget — Budget Periods привязаны к сезону
  • Quizzes — квизы генерируются для сезона (Step 7 Setup Wizard)
  • Quests — источник XP за выполнение заданий
  • Achievements — источник XP за разблокировку
  • Streaks — влияет на XP multiplier
  • Inventory — SEASON_REWARD items хранятся отдельно