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 | Тиры | Метрика |
|---|---|---|---|
| Общая активность | activity | 0–4 | Level |
| Рефералы | referrals | 1–4 | friendsInvited |
| Стрики | streaks | 1–4 | bestDailyLoginStreak |
| Квизы | quizzes | 1–3 | quizzesCompleted + accuracy% |
| Экономика | economy | 1–3 | scrapSpent (cases + craft + spins) |
| Квиз-специализация | quiz_spec | 2 | per-category: quizzes + accuracy |
| Rust: прогрессия | rust_general | 1–3 | uniqueRustTypes |
| Rust: специализация | rust_spec | 2–3 | dominance per event type |
| Крафт | craft | 3 | itemsCrafted |
| Raffle | raffle | 3 | (событийное) |
| Boost Pass | boost_pass | 0 | Динамические титулы из 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)
Полный список порогов
| Key | Default | Описание |
|---|---|---|
TITLE_ACTIVITY_LEVEL_1 | 5 | Level для тира 1 |
TITLE_ACTIVITY_LEVEL_2 | 15 | Level для тира 2 |
TITLE_ACTIVITY_LEVEL_3 | 30 | Level для тира 3 |
TITLE_ACTIVITY_TOP_N | 10 | Топ-N XP лидерборда |
TITLE_ACTIVITY_RETAIN_N | 15 | Retain-зона для позиционного тайтла activity (hysteresis) |
TITLE_REFERRALS_TIER_1 | 1 | Рефералов для тира 1 |
TITLE_REFERRALS_TIER_2 | 5 | Рефералов для тира 2 |
TITLE_REFERRALS_TIER_3 | 15 | Рефералов для тира 3 |
TITLE_REFERRALS_TOP_N | 3 | Топ-N реферального лидерборда |
TITLE_REFERRALS_RETAIN_N | 5 | Retain-зона для позиционного тайтла referrals (hysteresis) |
TITLE_STREAKS_TIER_1 | 7 | Дней стрика для тира 1 |
TITLE_STREAKS_TIER_2 | 14 | Дней стрика для тира 2 |
TITLE_STREAKS_TIER_3 | 28 | Дней стрика для тира 3 |
TITLE_STREAKS_TOP_N | 3 | Топ-N стрик-лидерборда |
TITLE_STREAKS_RETAIN_N | 5 | Retain-зона для позиционного тайтла streaks (hysteresis) |
TITLE_QUIZZES_COUNT_1 | 50 | Квизов для тира 1 |
TITLE_QUIZZES_ACCURACY_1 | 70 | Accuracy% для тира 1 |
TITLE_QUIZZES_COUNT_2 | 150 | Квизов для тира 2 |
TITLE_QUIZZES_ACCURACY_2 | 80 | Accuracy% для тира 2 |
TITLE_QUIZZES_COUNT_3 | 300 | Квизов для тира 3 |
TITLE_QUIZZES_ACCURACY_3 | 85 | Accuracy% для тира 3 |
TITLE_ECONOMY_TIER_1 | 5 000 | Scrap потрачено для тира 1 |
TITLE_ECONOMY_TIER_2 | 20 000 | Scrap потрачено для тира 2 |
TITLE_ECONOMY_TIER_3 | 50 000 | Scrap потрачено для тира 3 |
TITLE_QUIZ_SPEC_MIN_QUIZZES | 30 | Мин. квизов для специализации |
TITLE_QUIZ_SPEC_MIN_ACCURACY | 80 | Мин. accuracy% для специализации |
TITLE_RUST_TYPES_TIER_1 | 1 | Типов Rust-событий для тира 1 |
TITLE_RUST_TYPES_TIER_2 | 4 | Типов Rust-событий для тира 2 |
TITLE_RUST_TYPES_TIER_3 | 8 | Типов Rust-событий для тира 3 |
TITLE_RUST_SPEC_MIN_QUESTS | 5 | Мин. квестов для Rust-специализации |
TITLE_RUST_SPEC_MIN_PERCENT | 40 | Мин. доминантность% для Rust-специализации |
TITLE_POSITIONAL_MIN_PARTICIPANTS | 10 | Мин. участников в лидерборде для позиционных тайтлов |
TITLE_CRAFT_MIN_ITEMS | 1 | Мин. скрафченных предметов |
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 titlesoff— тег не отображается
4. Telegram Tag Sync (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 проверяются одновременно (оба должны быть ≥) |
| Пользователь без telegramId | Tag sync пропускает, помечает syncedToTg=true |
| Bot config error (kicked from group) | Tags остаются dirty для retry; user-level errors → mark synced |
| Boost_pass active title в cron | tagText берётся из 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 «Не показывать уведомления о званиях» →
localStoragepreference - Интегрировано в 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 |
| Позиционные тайтлы при tie | Dense rank: оба игрока получают звание |
| Raffle звание | Действует весь сезон (не сбрасывается между розыгрышами) |
| Positional titles revocation | Отзываются при выпадении из топа; threshold-based — append-only |
| Хранение порогов | GlobalSettings в БД, изменение влияет немедленно |
| Сезонный сброс | Синхронно при завершении сезона (смена статуса → COMPLETED) |
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| TitleUpdateJob | titles/jobs/title-update.job.ts | Hourly cron: расчёт + Telegram sync |
| TitleCalculationService | titles/services/title-calculation.service.ts | In-memory расчёт earned titles |
| TitleTelegramService | titles/services/title-telegram.service.ts | Batch-sync тегов в Telegram |
| TitleThresholdService | titles/services/title-threshold.service.ts | CRUD порогов через GlobalSettings |
| TitleService | titles/services/title.service.ts | User API: earned/unearned titles |
| TitleLifecycleService | titles/services/title-lifecycle.service.ts | Lifecycle operations |
| TitleRepository | titles/repositories/title.repository.ts | DB queries |
| Admin Routes | admin/routes/admin-titles.routes.ts | POST /sync, GET /stats |
| Admin Settings | admin/routes/admin-settings.routes.ts | GET/PUT thresholds |
| Title Definitions | titles/constants/title-definitions.ts | 38 определений (in-memory) |
| Title Thresholds | titles/constants/title-thresholds.ts | 32 дефолтных порога |
| User Controller | titles/controllers/user-title.controller.ts | User API handler |
Admin Frontend Components
| Компонент | Путь | Описание |
|---|---|---|
| TitleSettings | admin/src/components/settings/TitleSettings.tsx | Основной контейнер таба |
| TitleSection | admin/src/components/settings/titles/TitleSection.tsx | Секция по оси |
| TitleCard | admin/src/components/settings/titles/TitleCard.tsx | Карточка тайтла |
| SyncBlock | admin/src/components/settings/titles/SyncBlock.tsx | Блок управления sync |
| ThresholdInput | admin/src/components/settings/titles/ThresholdInput.tsx | Inline-редактирование порога |
| section-config | admin/src/components/settings/titles/section-config.ts | SECTION_CONFIG (11 осей) |
TMA Frontend Components
| Компонент | Путь | Описание |
|---|---|---|
| TitlesModal | frontend/src/components/titles/TitlesModal.tsx | Fullscreen модалка «Звания» (два таба: GoLoot / Rust) |
| AxisChainCard | frontend/src/components/titles/AxisChainCard.tsx | Карточка оси с carousel (earned + unearned slides) |
| EarnedTitleCard | frontend/src/components/titles/EarnedTitleCard.tsx | Карточка заработанного тайтла |
| UnearnedTitleCard | frontend/src/components/titles/UnearnedTitleCard.tsx | Карточка незаработанного тайтла с прогрессом |
| TitleHintModal | frontend/src/components/titles/TitleHintModal.tsx | Bottom-sheet с hintText для unearned тайтлов |
| TitleNotificationModal | frontend/src/components/titles/TitleNotificationModal.tsx | Модалка нового звания при входе в TMA |
| useTitles | frontend/src/hooks/useTitles.ts | API hook для GET /titles |
| useTitleNotification | frontend/src/hooks/useTitleNotification.ts | Hook проверки новых званий с последнего визита |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| UserTitle | Earned title за сезон (append-only) | userId, seasonId, slug, axis, tier, earnedAt, name?, tagText? |
| UserActiveTitle | Активный тег для Telegram sync | userId (unique), seasonId, titleSlug, tagText, syncedToTg, updatedAt |
Relationships
UserTitle имеет @@unique([userId, seasonId, slug]) — один тайтл на пользователя за сезон. UserActiveTitle имеет @unique userId — один активный тег на пользователя.
6. API Endpoints
- User API
- Admin API
| Метод | Эндпоинт | Описание |
|---|---|---|
| GET | /titles | Список earned + unearned тайтлов с прогрессом |
| GET | /titles/new | Получение новых непросмотренных тайтлов |
| PUT | /titles/display-mode | Изменить displayMode (auto/manual/off) |
| PUT | /titles/select | Выбрать тайтл для manual mode |
| POST | /titles/mark-seen | Пометить тайтлы как просмотренные |
| Метод | Эндпоинт | Описание | Timeout |
|---|---|---|---|
| GET | /admin/settings/titles | Получить все 32 порога + defaults | — |
| PUT | /admin/settings/titles | Partial update порогов | — |
| POST | /admin/titles/sync | Ручной запуск title update job | 120s |
| GET | /admin/titles/stats | Enriched definitions + counts + lastSync + isRunning | — |
POST /admin/titles/sync — Response Codes
| Code | Значение |
|---|---|
| 200 | Sync завершён (result в data) |
| 409 | ALREADY_RUNNING — cron или manual sync уже выполняется |
| 503 | SERVICE_UNAVAILABLE — Telegram бот не инициализирован |
PUT /admin/settings/titles — Validation
| Правило | Уровень |
|---|---|
Все значения > 0 | Backend (Schema: minimum: 1) |
| Accuracy ≤ 100 | Backend (route-level check) |
| Progression: tier N > tier N-1 | Frontend warning (не блокирует) |
| Accuracy 0–100, Top-N 1–100 | Frontend warning |
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-thresholds | activity, referrals, streaks, quizzes, economy, rust_general, craft | Карточки с inline-редактированием порогов |
shared-thresholds | quiz_spec, rust_spec | Общие пороги в шапке секции, карточки — только stats |
stats-only | raffle, boost_pass | Только статистика (событийное / динамические титулы) |
3. Sticky footer — кнопка «Сохранить изменения (N)», появляется при наличии staged changes.
Inline Editing UX
4 состояния поля:
- Idle — серверное значение, ✓/✕ скрыты
- Editing — значение изменено, ✓/✕ видны
- Confirmed — нажат ✓, значение «застейджено» (синяя рамка)
- Cancelled — нажат ✕, откат к серверному → Idle
Кнопка «Сохранить» отправляет только confirmed ключи (PUT /admin/settings/titles, partial update).
8. Related
- 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