Skip to main content

Budget Control & Luck Pool

1. Summary

Goal: Контроль экономики крафтов и поощрение активных игроков через систему буста вероятностей.

User Value: Активные игроки (50%+ прогресс крафта) получают × 3-12.9 увеличение шансов на материалы для целевых скинов через Boost.


2. Business Logic

Budget Periods

Экономика разбита на периоды для контроля расходов. Параметры настраиваются per-season через Setup Wizard (Step 2 — Budget):

ПараметрDefaultPer-season
Количество периодов9 (auto: ceil(durationDays / 10))budgetPeriodsCount (1-30)
Бюджет периода3333₽ (333₽/день × 10)budgetTotalRub / periodsCount
Общий бюджет333₽ × durationDays (auto)budgetTotalRub (1000-10M, optional override)
Перенос остатковВключёнbudgetCarryoverEnabled (toggle)
Перенос бюджета
  • Внутри месяца: остаток переносится в следующий период (если budgetCarryoverEnabled = true)
  • В конце месяца (каждые 3 периода): остаток сгорает
  • Carryover можно отключить per-season через Setup Wizard

Luck Pool

Механика поощрения активных игроков через увеличение вероятностей.

Вкратце: Игроки с ≥50% прогрессом крафта получают буст × 3-12.9 к материалам.

Подробная документация

См. Luck Pool для полного описания механики, seniority, anti-abuse, edge cases.

Budget Exhausted

При исчерпании бюджета периода

Штрафной множитель: × 0.01 (99% снижение вероятности)

Применяется ко всем FRAGMENT/BLUEPRINT с targetSkinId (все пользователи, не только участники Luck Pool). Остальные награды не затрагиваются.

Определение: available = baseBudget + carriedOver - spent <= 0

Уведомление: Админ получает alert только при переходе из "хватает" в "не хватает".

Budget НЕ блокирует крафты

Игроки могут крафтить даже при исчерпанном бюджете. Система только логирует траты для администратора.

Manual Budget Lock

Экстренная кнопка администратора — принудительно переводит все кейсы и спины в режим исчерпанного бюджета (снижает шансы FRAGMENT/BLUEPRINT), независимо от реального состояния бюджета.

ПараметрРешение
ТипМягкая блокировка — снижает вес FRAGMENT/BLUEPRINT (= автоматическое budget exhaustion)
ПокрытиеВсе кейсы (free, paid, streak points) + все спины текущего сезона
ГранулярностьПер-сезон — привязана к активному сезону
UX пользователяТихо — ничего не показывает, просто реже выпадают редкие предметы
ДеактивацияТолько ручная, без таймера
ПричинаОпциональное текстовое поле при активации (до 500 символов)

Механизм: BudgetService.isBudgetExhausted(seasonId) — единственная точка входа для обоих case-opening.service.ts и user-spin.service.ts. Проверка Season.isManualBudgetLock добавлена первой в этом методе — оба сервиса автоматически получают нужное поведение без изменений.

Приоритет ручной блокировки

Ручная блокировка имеет приоритет над автоматическим расчётом бюджета. Если isManualBudgetLock = true, isBudgetExhausted() возвращает true независимо от остатка бюджета.

Admin UI: скрытый доступ

Панель ручной блокировки доступна через Ctrl+Alt+M в BudgetStep (Step 2 Season Setup Wizard, ACTIVE mode).

Когда блокировка активна — рядом с заголовком появляется маленькая красная точка-индикатор.

Admin Notifications (TG Alerts)

Budget Control отправляет уведомления администраторам через Telegram при критических событиях:

СобытиеТипКогда отправляетсяInline кнопки
Бюджет исчерпанBUDGET_EXHAUSTEDПри первом исчерпании бюджета периода+500₽, +1000₽, +2000₽, +5000₽, Открыть бюджет
Подозрительная активностьSUSPICIOUS_CRAFTSПри >2 крафтах за 24ч одним пользователемПроверить пользователя
Смена периодаPERIOD_TRANSITIONПри автоматическом переходе между периодамиОткрыть бюджет
Блокировка активированаBUDGET_LOCK_ACTIVATEDПри ручной активации блокировки администратором👀 Открыть бюджет
Блокировка снятаBUDGET_LOCK_DEACTIVATEDПри ручной деактивации блокировки администратором👀 Открыть бюджет
Формат уведомлений

BUDGET_EXHAUSTED:

⚠️ Бюджет исчерпан!

Сезон: {seasonNumber}
Период: {periodNumber}
День периода: {dayOfPeriod}
Буст отключен до следующего периода.

[+500₽] [+1000₽] [+2000₽] [+5000₽]
[Открыть бюджет]

SUSPICIOUS_CRAFTS:

🚨 Подозрительная активность!

Пользователь: {username || userId}
Крафтов за 24ч: {craftsIn24h}
Порог: {threshold}

[Проверить пользователя]

PERIOD_TRANSITION:

📅 Смена периода

Сезон: {seasonNumber}
Переход: период {fromPeriod} → {toPeriod}
Перенесено: {budgetCarriedOver}₽
Сгорело: {budgetExpired}₽

[Открыть бюджет]

BUDGET_LOCK_ACTIVATED:

🔒 Блокировка выпадения АКТИВИРОВАНА

Сезон: #{seasonNumber}
Администратор: {adminName}
Время: {datetime} UTC
📝 Причина: {reason} ← если указана

Выпадение редких предметов (FRAGMENT/BLUEPRINT) ограничено до снятия блокировки.

BUDGET_LOCK_DEACTIVATED:

🔓 Блокировка выпадения СНЯТА

Сезон: #{seasonNumber}
Администратор: {adminName}
Время: {datetime} UTC

Выпадение редких предметов восстановлено в штатный режим.
Callback handlers

Кнопки admin:budget_add_{amount} обрабатываются в backend/src/domains/telegram/handlers/admin-callback.handler.ts и вызывают BudgetAdminService.incrementBudget().

Edge Cases

СитуацияПоведение
Бюджет исчерпанШтраф × 0.01 ко всем крафтовым материалам (FRAGMENT/BLUEPRINT)
Конец месяцаОстаток бюджета сгорает
Конец периодаОстаток переносится в следующий период
Крафт в сезоне A, вывод в сезоне BspentRub учтён в сезоне A (по крафту). Withdrawal не привязан к сезону — это delivery, не commitment
Ручная блокировка активнаisBudgetExhausted() возвращает true независимо от остатка бюджета периода
Carryover отключён per-seasonbudgetCarryoverEnabled = false → остаток не переносится между периодами (ни внутри месяца, ни в конце)
budgetTotalRub = nullАвто-расчёт: DAILY_BUDGET_RUB × durationDays (default поведение)
Luck Pool Edge Cases

Edge cases связанные с пулом удачи (вход, выход, seniority) описаны в Luck Pool.


3. ADR (Architectural Decisions)

Luck Pool ADRs

Архитектурные решения по Luck Pool (буст, seniority) описаны в Luck Pool ADR.

Почему per-season конфигурация бюджета?

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

Решение: Три новых поля в Season model:

  • budgetPeriodsCount (Int, default 9) — количество периодов
  • budgetTotalRub (Int?, null = auto) — override общего бюджета
  • budgetCarryoverEnabled (Boolean, default true) — переключатель carryover

Настраиваются через Step 2 (Budget) в Season Setup Wizard до активации сезона.

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

  • Отдельная таблица BudgetConfig — избыточно для 3 полей
  • Оставить глобальные константы — не позволяет варьировать бюджет между сезонами

Последствия: Гибкость per-season, глобальные константы (BUDGET_CONTROL) остаются как defaults. createSeasonPeriods() принимает periodsCount из Season model. handlePeriodTransition() читает budgetCarryoverEnabled из Season.

Почему Budget не блокирует крафты?

Проблема: При блокировке крафтов игроки потеряют прогресс.

Решение: Budget — инструмент мониторинга, а не ограничения.

Последствия:

  • Админ контролирует экономику через отмену выводов
  • Игроки не теряют прогресс из-за бюджета

Почему поля блокировки в Season, а не BudgetPeriod?

Проблема: Где хранить состояние ручной блокировки — в BudgetPeriod или Season?

Решение: Поля isManualBudgetLock, budgetLockActivatedAt, budgetLockActivatedBy, budgetLockReason хранятся в модели Season.

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

  • BudgetPeriod — отклонён: блокировка пер-сезон, а не пер-период. При смене периода блокировка должна сохраняться
  • Отдельная таблица BudgetLock — отклонён: избыточно для 4 полей, лишний JOIN

Последствия: Блокировка переживает transitions между периодами. Один findUnique по Season вместо дополнительного JOIN.

Почему isBudgetExhausted() — единственная точка входа?

Проблема: Блокировка должна одновременно действовать на кейсы (case-opening) и спины (user-spin).

Решение: BudgetService.isBudgetExhausted(seasonId) — единственная функция, которую вызывают оба сервиса. Добавление проверки isManualBudgetLock в неё автоматически покрывает оба пути без изменений в consumers.

Последствия: Минимальные изменения (1 файл для core логики), нулевой риск рассинхронизации между кейсами и спинами.

Почему расход фиксируется при крафте, а не при выводе?

Проблема: Крафт и вывод могут произойти в разных сезонах. Игрок крафтит скин в последний день сезона A, но выводит уже в сезоне B.

Решение: Budget трекает commitment (обязательство платформы), а не delivery (фактическую доставку).

Аналогия: Бухгалтерский "accrued expense" — расход фиксируется когда возникает обязательство, а не когда деньги уходят.

Последствия:

  • spentRub = сумма обязательств платформы за период
  • Withdrawal не имеет periodId — это operational detail
  • Админ контролирует реальные расходы через отмену выводов
  • Скин в инвентаре = платформа уже "должна" его игроку

4. Architecture

Integration Overview

Key Components

КомпонентПутьОписание
BudgetServicebackend/src/domains/budget/services/budget.service.tsCore: статус, запись расходов, проверки
BudgetAdminServicebackend/src/domains/budget/services/budget-admin.service.tsAdmin: increment/decrement/reset бюджета, ручная блокировка
BudgetLifecycleServicebackend/src/domains/budget/services/budget-lifecycle.service.tsLifecycle: создание периодов, transitions
LuckPoolServicebackend/src/domains/luck-pool/services/luck-pool.service.tsУправление пулом и бустом
BudgetOperationsServicebackend/src/domains/budget/services/budget-operations.service.tsUnified view операций (крафты + выводы) для админки
WithdrawalAdminServicebackend/src/domains/budget/services/budget-withdrawal.service.tsОтмена выводов администратором
BudgetRepositorybackend/src/domains/budget/repositories/budget.repository.tsCRUD для BudgetPeriod
Constantsshared/src/constants/budgetControl.tsКонстанты-defaults (overridable per-season через Season model)
Константы системы (defaults, overridable per-season)
BUDGET_CONTROL = {
PERIOD_DAYS: 10,
PERIODS_PER_MONTH: 3,
DAILY_BUDGET_RUB: 333,
PERIOD_BUDGET_RUB: 3333,

MIN_PROGRESS_FOR_POOL: 0.5,
INACTIVE_DAYS_THRESHOLD: 14,
POOL_CHECK_INTERVAL_HOURS: 12,

BASE_BOOST_MULTIPLIER: 3.0,
SENIORITY: {
MULTIPLIER_PER_PERIOD: 1.2,
MAX_ACTIVE_PERIODS: 9,
},

BUDGET_EXHAUSTED_MULTIPLIER: 0.01,

BUDGET_CARRYOVER: {
ENABLED: true,
WITHIN_MONTH_ONLY: true,
},

BLOCKED_SKINS: {
CLEAR_INTERVAL_PERIODS: 3,
},

ACTIVITY_ACTIONS: ['CASE_OPENED', 'SPIN_USED', 'STREAK_BONUS_CLAIMED'],

ADMIN: {
BUDGET_INCREMENT_PRESETS: [500, 1000, 2000, 5000],
BUDGET_INCREMENT_MIN: 100,
BUDGET_INCREMENT_MAX: 10000,
BUDGET_DECREMENT_PRESETS: [500, 1000],
BUDGET_DECREMENT_MIN: 100,
SUSPICIOUS_CRAFTS_THRESHOLD: 2,
},
}

5. Database Schema

Models

МодельОписаниеКлючевые поля
BudgetPeriodПериод бюджетаbaseBudgetRub, carriedOverRub, spentRub, isActive
LuckPoolEntryУчастник пулаactivePeriods, boostMultiplier, isActive, blockedSkinIds
CraftBudgetLogЛог крафтовcostRub, hadBoost, seniorityMultiplier, totalBoostMultiplier
Season (budget fields)Конфигурация + ручная блокировкаbudgetPeriodsCount, budgetTotalRub, budgetCarryoverEnabled, isManualBudgetLock, budgetLockActivatedAt, budgetLockActivatedBy, budgetLockReason

Relationships


6. API Endpoints

МетодЭндпоинтОписание
GET/admin/budget/statusСтатус текущего периода
GET/admin/budget/periodsВсе периоды сезона
POST/admin/budget/periods/:periodId/incrementУвеличить бюджет
POST/admin/budget/periods/:periodId/decrementУменьшить бюджет
POST/admin/budget/periods/:periodId/resetСбросить бюджет к значению по умолчанию
GET/admin/budget/craft-logsЛоги крафтов
GET/admin/budget/operationsUnified список операций (крафты + выводы)
GET/admin/budget/operations/pending-countКоличество ожидающих выводов (для badge)
POST/admin/budget/withdrawals/:id/cancelОтменить вывод (только PENDING/PROCESSING)
POST/admin/budget/lockАктивировать ручную блокировку (body: { reason?: string })
DELETE/admin/budget/lockСнять ручную блокировку

  • Luck Pool — полная документация по пулу удачи
  • Cases — использует Luck Pool для буста материалов
  • Daily Spins — использует Luck Pool для буста материалов
  • Craft — триггерит выход из пула и записывает расходы
  • Seasons — контекст для периодов и пула