Skip to main content

Titles (Звания)

1. Summary

Goal: Мотивация через публичные звания — 38+ тайтлов по 11 осям отражают игровые достижения пользователя и отображаются как Telegram-теги в группе через setChatMemberTag (Bot API 9.5).

User Value: Видимый статус в Telegram-группе, мотивация к прогрессу (стрики, квизы, рефералы), социальное признание.

Business Goals:

  • Комьюнити-эффект: Пользователи без тегов видят, что «все вокруг с приписками», интересуются, вовлекаются
  • Органический рост: Теги провоцируют вопросы в чате — естественная реклама MiniApp
  • Мотивация: Визуальный статус стимулирует активность внутри приложения
  • Удержание: Сезонный сброс мотивирует играть каждый сезон заново

2. Business Logic

Title Structure

38 хардкод-тайтлов распределены по 11 осям и 5 тирам (0–4):

Tier Hierarchy

ТирЭмодзиЗначение~% активных
0(без эмодзи)Базовый (Путник)100%
1Начальный~60–70%
2Средний~20–30%
3🔥Высокий~5–10%
4👑Элитный (позиционный)~1–3%

Формат тега: {эмодзи} {название} (например, 🔥 Хардкорщик). Язык тегов — русский.

Кумулятивные тиры

Тиры внутри оси кумулятивны: при достижении tier 3 пользователь автоматически получает tier 1 и tier 2 той же оси. UserTitle — append-only для threshold-based тайтлов. Позиционные (tier 4) — динамические, отзываются при выпадении из топа.

ОсьAxis keyТирыМетрика
Общая активностьactivity0–4Level
Рефералыreferrals1–4friendsInvited
Стрикиstreaks1–4bestDailyLoginStreak
Квизыquizzes1–3quizzesCompleted + accuracy%
Экономикаeconomy1–3scrapSpent (cases + craft + spins)
Квиз-специализацияquiz_spec2per-category: quizzes + accuracy
Rust: прогрессияrust_general1–3uniqueRustTypes
Rust: специализацияrust_spec2–3dominance per event type
Крафтcraft3itemsCrafted
Raffleraffle3(событийное)
Boost Passboost_pass0Динамические титулы из Boost Pass milestones

Dynamic Titles (Boost Pass)

В отличие от 38 хардкод-тайтлов (определённых в TITLE_DEFINITIONS), boost_pass титулы — динамические:

  • Админ задаёт titleName при настройке milestone reward в Boost Pass
  • При grant награды titleName денормализуется в UserTitle.name и UserTitle.tagText (name = tagText)
  • Title service использует fallback-цепочку: TITLE_BY_SLUG[slug]UserTitle.name/tagText → slug

Бизнес-правила:

  • Только Premium-юзеры видят ось boost_pass в списке званий
  • Earned титул остаётся до конца сезона, даже если Premium потерян
  • 1-5 титулов за сезон, гибко через админку
  • Отображаются во вкладке GoLoot как unique axis "Boost Pass"
  • Юзер может выбрать boost_pass титул как active (manual mode)

Threshold Types

ТипПримерКак определяется
baseПутник (tier 0)Выдаётся всем с UserSeasonStats
thresholdГриндер (Level ≥ 15)Значение из GlobalSettings, сравнение >=
positionalЛегенда сезона (Топ-10)Позиция в лидерборде
eventФартовыйВыдаётся по факту события (выигрыш raffle)
shared-thresholdОружейникОбщие пороги для всей секции (MIN_QUIZZES + MIN_ACCURACY)

Default Thresholds (33 keys)

Полный список порогов
KeyDefaultОписание
TITLE_ACTIVITY_LEVEL_15Level для тира 1
TITLE_ACTIVITY_LEVEL_215Level для тира 2
TITLE_ACTIVITY_LEVEL_330Level для тира 3
TITLE_ACTIVITY_TOP_N10Топ-N XP лидерборда
TITLE_ACTIVITY_RETAIN_N15Retain-зона для позиционного тайтла activity (hysteresis)
TITLE_REFERRALS_TIER_11Рефералов для тира 1
TITLE_REFERRALS_TIER_25Рефералов для тира 2
TITLE_REFERRALS_TIER_315Рефералов для тира 3
TITLE_REFERRALS_TOP_N3Топ-N реферального лидерборда
TITLE_REFERRALS_RETAIN_N5Retain-зона для позиционного тайтла referrals (hysteresis)
TITLE_STREAKS_TIER_17Дней стрика для тира 1
TITLE_STREAKS_TIER_214Дней стрика для тира 2
TITLE_STREAKS_TIER_328Дней стрика для тира 3
TITLE_STREAKS_TOP_N3Топ-N стрик-лидерборда
TITLE_STREAKS_RETAIN_N5Retain-зона для позиционного тайтла streaks (hysteresis)
TITLE_QUIZZES_COUNT_150Квизов для тира 1
TITLE_QUIZZES_ACCURACY_170Accuracy% для тира 1
TITLE_QUIZZES_COUNT_2150Квизов для тира 2
TITLE_QUIZZES_ACCURACY_280Accuracy% для тира 2
TITLE_QUIZZES_COUNT_3300Квизов для тира 3
TITLE_QUIZZES_ACCURACY_385Accuracy% для тира 3
TITLE_ECONOMY_TIER_15 000Scrap потрачено для тира 1
TITLE_ECONOMY_TIER_220 000Scrap потрачено для тира 2
TITLE_ECONOMY_TIER_350 000Scrap потрачено для тира 3
TITLE_QUIZ_SPEC_MIN_QUIZZES30Мин. квизов для специализации
TITLE_QUIZ_SPEC_MIN_ACCURACY80Мин. accuracy% для специализации
TITLE_RUST_TYPES_TIER_11Типов Rust-событий для тира 1
TITLE_RUST_TYPES_TIER_24Типов Rust-событий для тира 2
TITLE_RUST_TYPES_TIER_38Типов Rust-событий для тира 3
TITLE_RUST_SPEC_MIN_QUESTS5Мин. квестов для Rust-специализации
TITLE_RUST_SPEC_MIN_PERCENT40Мин. доминантность% для Rust-специализации
TITLE_POSITIONAL_MIN_PARTICIPANTS10Мин. участников в лидерборде для позиционных тайтлов
TITLE_CRAFT_MIN_ITEMS1Мин. скрафченных предметов

Core Mechanics

1. Hourly Cron (TitleUpdateJob)

  • Запускается каждый час (:00)
  • Загружает thresholds из GlobalSettings, batch-загружает всех пользователей + агрегации
  • Вычисляет earned titles in-memory (O(1) queries), пишет diff в UserTitle
  • Позиционные тайтлы (tier 4): отзываются если пользователь выпал из топа; требуют минимум TITLE_POSITIONAL_MIN_PARTICIPANTS участников в лидерборде
  • Определяет active title по displayMode (auto / manual / off)
  • Batch-sync dirty tags в Telegram через Bot API

2. Threshold-based: append-only, Positional: dynamic

Разные модели для разных типов

Threshold-based (tier 0–3): UserTitle записи никогда не удаляются — стимулирует прогресс без страха потери.

Positional (tier 4): UserTitle записи отзываются при выпадении из топа. Тайтл существует только пока пользователь удерживает позицию в лидерборде. При повторном попадании в топ — тайтл восстанавливается, но модалка уведомления не показывается повторно (slug сохраняется в lastSeenTitleSlugs через UNION-merge).

3. Display Mode

  • auto — показывается тайтл наивысшего тира (при равном тире — приоритет по порядку осей)
  • manual — пользователь выбирает из earned titles
  • off — тег не отображается

4. Telegram Tag Sync (Self-healing)

Self-healing подход

Cron НЕ фильтрует по isInGroup. Пытается отправить тег всем с telegramId. Результат API сам обновляет isInGroup: успех → true, "user not found" → false. Это позволяет корректно обрабатывать первый запуск (когда у всех isInGroup=false).

Edge Cases

СитуацияПоведение
Нет активного сезонаCron пропускает; admin: пороги редактируемы, stats = «—», sync disabled
Cron + manual одновременноisRunning lock → 409 ALREADY_RUNNING (try/finally для надёжности)
Путник (tier 0)Выдаётся всем с UserSeasonStats; base type, не threshold
Повышение порогаThreshold-based звания не отбираются; admin получает warning
Позиционный тайтл отозванActive title пересчитывается (fallback на auto); при selectedTitleSlug = отозванный → авто-восстановление при возврате в топ
Лидерборд < 10 участниковПозиционные тайтлы не выдаются (TITLE_POSITIONAL_MIN_PARTICIPANTS)
Повторное получение позиционного тайтлаМодалка уведомления не показывается — slug остаётся в lastSeenTitleSlugs
Telegram бот не запущенtitleJobs = undefined → sync endpoint отвечает 503
Квизы: два порогаCount + Accuracy проверяются одновременно (оба должны быть ≥)
Пользователь без telegramIdTag sync пропускает, помечает syncedToTg=true
Bot config error (kicked from group)Tags остаются dirty для retry; user-level errors → mark synced
Boost_pass active title в crontagText берётся из UserTitle.tagText (fallback, т.к. динамические титулы не в TITLE_BY_SLUG)
User потерял Premium после boost_pass титулаТитул остаётся в earned, можно выбирать как active до конца сезона
Выбор dynamic slug в selectTitleКонтроллер проверяет `TITLE_BY_SLUG[slug]
Пользователь не видит свой тегПодтверждено: Telegram не отображает member tag пользователю на его собственных сообщениях. Другие участники группы видят тег. Проверено на одном устройстве с двумя аккаунтами (один клиент, разные аккаунты). Это поведение платформы Telegram, не баг системы
Канал vs ГруппаПользователь подписан на канал, но не вступил в привязанную группу обсужденийisInGroup = false, тег не ставится. Канал и группа — разные сущности в Telegram

Telegram API

Метод setChatMemberTag (Bot API 9.5, 1 марта 2026):

  • Бот должен быть добавлен в привязанную группу (не в канал)
  • Требуются права администратора с can_manage_tags
  • Группа — супергруппа (если привязана к каналу, уже является)
  • Теги видны другим участникам в сообщениях группы + в комментариях под постами канала
  • Пользователь не видит свой собственный тег на своих сообщениях (поведение Telegram)
  • Rate limit: ~30 req/sec суммарно. Cron использует задержку 50ms (~20 req/sec)

Title Hints

Иконка ⓘ на карточках незаработанных тайтлов. Тап → bottom-sheet модалка с требованием и советом.

  • Каждый тайтл в TITLE_DEFINITIONS содержит поле hintText
  • API: GET /titles возвращает hintText в UnearnedTitleDto
  • Earned тайтлы не содержат hint — только unearned
  • Positional (tier 4): требование с акцентом на удержание позиции
  • UX: паттерн QuestHintModal (bottom-sheet, CloseButton, useScrollLock)

Title Notifications

При входе в TMA — проверка на новые звания с последнего визита.

  • Показывается одно самое высокое новое звание
  • Если новых больше одного — текст «и ещё N новых званий»
  • Кнопка «Посмотреть все» → переход на страницу «Звания»
  • Checkbox «Не показывать уведомления о званиях» → localStorage preference
  • Интегрировано в App lifecycle после consolation rewards

3. ADR (Architectural Decisions)

Почему lastResult хранится in-memory?

Проблема: При загрузке admin-страницы нет данных о последнем запуске cron. Результат runNow() возвращается только в HTTP-ответе.

Решение: Поле lastResult: TitleUpdateResult & { timestamp: Date } | null на инстансе TitleUpdateJob. Обновляется после каждого execute().

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

  • Хранение в БД — избыточно для admin-инструмента (YAGNI)
  • Redis — лишняя зависимость для одного поля

Последствия: При рестарте сервера → null до первого cron-цикла (≤1 час). Приемлемо для admin-панели.

Почему синхронный sync с timeout 120s?

Проблема: Telegram sync может занять 25+ секунд (500 dirty tags × 50ms).

Решение: request.raw.setTimeout(120000) для POST /admin/titles/sync. Админ видит спиннер и ждёт.

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

  • Fire-and-forget + polling — избыточная сложность для admin-инструмента с 1 пользователем
  • WebSocket — ещё сложнее, не оправдано

Последствия: Если >120 сек — это инцидент (Telegram down), нужно расследование.

Почему DRY через enriched stats endpoint?

Проблема: 38 определений тайтлов — если захардкодить на фронте, нарушение DRY.

Решение: GET /admin/titles/stats возвращает обогащённые данные — каждая запись содержит полное определение + count. Фронт хранит только SECTION_CONFIG (11 записей UI-конфигурации).

Последствия: Добавить новый тайтл → только бэкенд. Добавить новую ось → бэкенд + 1 строка в SECTION_CONFIG.

Почему isRunning lock с try/finally?

Проблема: Плановый cron и ручной sync могут запуститься одновременно → двойной Telegram sync.

Решение: isRunning flag. finally — критично: без него exception → isRunning = true навсегда → cron мёртв до рестарта.

Критичная деталь

finally { this.isRunning = false } обязателен. Без него один необработанный exception блокирует все будущие sync-циклы.

Зафиксированные решения

ВопросРешение
Язык теговРусский
Система отделена от achievementsДа — cron + агрегатные статы, не связана с achievement flow
Позиционные тайтлы при tieDense rank: оба игрока получают звание
Raffle званиеДействует весь сезон (не сбрасывается между розыгрышами)
Positional titles revocationОтзываются при выпадении из топа; threshold-based — append-only
Хранение пороговGlobalSettings в БД, изменение влияет немедленно
Сезонный сбросСинхронно при завершении сезона (смена статуса → COMPLETED)

4. Architecture

Services Overview

Key Components

КомпонентПутьОписание
TitleUpdateJobtitles/jobs/title-update.job.tsHourly cron: расчёт + Telegram sync
TitleCalculationServicetitles/services/title-calculation.service.tsIn-memory расчёт earned titles
TitleTelegramServicetitles/services/title-telegram.service.tsBatch-sync тегов в Telegram
TitleThresholdServicetitles/services/title-threshold.service.tsCRUD порогов через GlobalSettings
TitleServicetitles/services/title.service.tsUser API: earned/unearned titles
TitleLifecycleServicetitles/services/title-lifecycle.service.tsLifecycle operations
TitleRepositorytitles/repositories/title.repository.tsDB queries
Admin Routesadmin/routes/admin-titles.routes.tsPOST /sync, GET /stats
Admin Settingsadmin/routes/admin-settings.routes.tsGET/PUT thresholds
Title Definitionstitles/constants/title-definitions.ts38 определений (in-memory)
Title Thresholdstitles/constants/title-thresholds.ts32 дефолтных порога
User Controllertitles/controllers/user-title.controller.tsUser API handler

Admin Frontend Components

КомпонентПутьОписание
TitleSettingsadmin/src/components/settings/TitleSettings.tsxОсновной контейнер таба
TitleSectionadmin/src/components/settings/titles/TitleSection.tsxСекция по оси
TitleCardadmin/src/components/settings/titles/TitleCard.tsxКарточка тайтла
SyncBlockadmin/src/components/settings/titles/SyncBlock.tsxБлок управления sync
ThresholdInputadmin/src/components/settings/titles/ThresholdInput.tsxInline-редактирование порога
section-configadmin/src/components/settings/titles/section-config.tsSECTION_CONFIG (11 осей)

TMA Frontend Components

КомпонентПутьОписание
TitlesModalfrontend/src/components/titles/TitlesModal.tsxFullscreen модалка «Звания» (два таба: GoLoot / Rust)
AxisChainCardfrontend/src/components/titles/AxisChainCard.tsxКарточка оси с carousel (earned + unearned slides)
EarnedTitleCardfrontend/src/components/titles/EarnedTitleCard.tsxКарточка заработанного тайтла
UnearnedTitleCardfrontend/src/components/titles/UnearnedTitleCard.tsxКарточка незаработанного тайтла с прогрессом
TitleHintModalfrontend/src/components/titles/TitleHintModal.tsxBottom-sheet с hintText для unearned тайтлов
TitleNotificationModalfrontend/src/components/titles/TitleNotificationModal.tsxМодалка нового звания при входе в TMA
useTitlesfrontend/src/hooks/useTitles.tsAPI hook для GET /titles
useTitleNotificationfrontend/src/hooks/useTitleNotification.tsHook проверки новых званий с последнего визита

5. Database Schema

Models

МодельОписаниеКлючевые поля
UserTitleEarned title за сезон (append-only)userId, seasonId, slug, axis, tier, earnedAt, name?, tagText?
UserActiveTitleАктивный тег для Telegram syncuserId (unique), seasonId, titleSlug, tagText, syncedToTg, updatedAt

Relationships

Unique constraint

UserTitle имеет @@unique([userId, seasonId, slug]) — один тайтл на пользователя за сезон. UserActiveTitle имеет @unique userId — один активный тег на пользователя.


6. API Endpoints

МетодЭндпоинтОписание
GET/titlesСписок earned + unearned тайтлов с прогрессом
GET/titles/newПолучение новых непросмотренных тайтлов
PUT/titles/display-modeИзменить displayMode (auto/manual/off)
PUT/titles/selectВыбрать тайтл для manual mode
POST/titles/mark-seenПометить тайтлы как просмотренные

7. Admin Panel — Вкладка «Звания»

Таб «Звания» (Award icon) в Settings. Доступ: admin-only.

Layout

1. SyncBlock — кнопка «Обновить» + result-блок (timestamp, usersProcessed, titlesChanged, tagsSynced, tagsSkipped, tagsFailed, durationMs)

2. 11 секций — сгруппированы по осям, сортированы по SECTION_CONFIG.order:

Тип секцииОсиОписание
cards-with-thresholdsactivity, referrals, streaks, quizzes, economy, rust_general, craftКарточки с inline-редактированием порогов
shared-thresholdsquiz_spec, rust_specОбщие пороги в шапке секции, карточки — только stats
stats-onlyraffle, boost_passТолько статистика (событийное / динамические титулы)

3. Sticky footer — кнопка «Сохранить изменения (N)», появляется при наличии staged changes.

Inline Editing UX

4 состояния поля:

  1. Idle — серверное значение, ✓/✕ скрыты
  2. Editing — значение изменено, ✓/✕ видны
  3. Confirmed — нажат ✓, значение «застейджено» (синяя рамка)
  4. Cancelled — нажат ✕, откат к серверному → Idle

Кнопка «Сохранить» отправляет только confirmed ключи (PUT /admin/settings/titles, partial update).


  • Seasons — titles привязаны к сезону
  • Quizzes — quiz_spec тайтлы зависят от per-category stats
  • Streaks — streak тайтлы зависят от bestDailyLoginStreak
  • Referrals — referral тайтлы
  • Rust Integration — rust_general / rust_spec тайтлы
  • Boost Pass — boost_pass динамические титулы из milestone rewards