Skip to main content

Quests System

1. Summary

Goal: Система заданий для направления активности пользователей. Квесты стимулируют выполнение целевых действий (квизы, кейсы, подписки) и награждают за достижение целей.

User Value: Понятные цели + гарантированные награды. Пользователь видит конкретные задачи с прогрессом и может планировать свой путь к наградам.


2. Business Logic

Quest Types (Временные периоды)

Срок: Бессрочный

Reset: Нет сброса прогресса

Примеры: "Пройди 100 квизов", "Открой 50 кейсов"

Цель: Квесты без дедлайна — подписки на спонсоров, разовые акции

Quest Categories (Типы действий)

Действие: Прохождение квизов

Conditions:

  • categoryId — только квизы из категории
  • subcategoryId — только из подкатегории
  • slug — конкретный квиз (например "mp5a4")
  • entityType — тип сущности (item, monument, mechanic, npc, vehicle)

Пример: "Пройди 5 квизов про оружие" → { categoryId: "weapon" }

Core Mechanics

1. Shared Progress

Одно действие засчитывается во ВСЕ подходящие квесты одновременно.

Игрок прошёл квиз из категории "Оружие"
→ +1 к квесту "Пройди 5 квизов про оружие"
→ +1 к квесту "Пройди 10 любых квизов"
→ +1 к ежедневному "Пройди 3 квиза"
Реализация

QuestProgressService.incrementCategoryProgress() ищет ВСЕ активные квесты нужной категории, фильтрует по conditions, увеличивает прогресс в каждом в рамках одной транзакции.

Transaction Boundary

Quest progress hooks вызываются ПОСЛЕ завершения $transaction, не внутри неё. Это предотвращает deadlock-и и обеспечивает атомарность бизнес-операции.

// Правильно:
const result = await this.prisma.$transaction(async (tx) => { ... });
await this.questProgressService.incrementCategoryProgress(userId, 'ECONOMY', conditions, amount);

// Неправильно — внутри транзакции:
await this.prisma.$transaction(async (tx) => {
await this.questProgressService.incrementCategoryProgress(...); // ЗАПРЕЩЕНО
});

2. Lazy Reset (Batch)

Прогресс DAILY/WEEKLY квестов сбрасывается НЕ по cron, а при первом запросе списка квестов после истечения периода. Используется batch-оптимизация для снижения N+1 queries.

Пользователь запрашивает /quests

shouldReset() — pure function проверяет каждый userQuest в памяти

Собирает IDs для сброса → resetBatch() одним UPDATE
Награда не сгорает

Если квест COMPLETED но награда не забрана — сброс НЕ происходит. Пользователь может забрать награду в следующую сессию.

3. Quest Rotation System

Из большого пула DAILY/WEEKLY/PERMANENT квестов автоматически выбираются фиксированные наборы для показа пользователям.

Основные принципы
  • Детерминированность: Все пользователи видят одинаковые квесты (seeded shuffle)
  • Отдельный пул Rust: Rust квесты ротируются независимо от обычных
  • Запрет повторов: Квесты не повторяются в течение сезона (пока пул не исчерпан)
  • Персональные исключения: Выполненные isUnique квесты исключаются из ротации

Лимиты (хранятся в модели Season):

ТипRegularRustПериод
DAILYdailyQuestLimit (default 2)dailyRustQuestLimit (default 2)Каждый день
WEEKLYweeklyQuestLimit (default 1)weeklyRustQuestLimit (default 1)Каждую неделю
PERMANENTВсеВсеВесь сезон

Алгоритм выбора (stateless, deterministic):

1. Загрузить лимиты из Season model
2. Загрузить активные квесты сезона (через SeasonQuest)
3. Получить персональные исключения пользователя (isUnique)
4. Разделить квесты на regular и rust пулы
5. Применить исключения
6. Для DAILY/WEEKLY:
- Seeded shuffle пула (seed = type + seasonId)
- Pool cycling: startIdx = (offset * limit) % poolSize
- offset = дней/недель с начала сезона
7. PERMANENT: показать все
8. EVENT: фильтр по startAt/endAt

Seed для детерминированности:

ТипФормат seedПример
DAILY regulardaily-{seasonId}daily-season-1
DAILY rustdaily-{seasonId}-rustdaily-season-1-rust
WEEKLY regularweekly-{seasonId}weekly-season-1
WEEKLY rustweekly-{seasonId}-rustweekly-season-1-rust

4. Reward Pool (Ограниченный пул наград)

Квесты с rewardPool поддерживают конечное количество наград (first-come-first-served). Создаёт ощущение срочности и конкуренции.

  • Любой тип награды: ITEM, SCRAP, XP, CASE, STREAK_POINTS
  • Одна награда на юзера: Pool-квест автоматически isUnique = true
  • Только PERMANENT и EVENT: DAILY/WEEKLY не поддерживаются (validation error)
  • Счётчик: UI показывает "Осталось: N" (без total)
  • Исчерпанный квест: Остаётся видимым, смещается вниз в списке
  • Dismiss: Через UserQuestExclusion с reason pool_exhausted
  • Admin: Может увеличить пул, но не уменьшить ниже уже забранного (rewardPoolClaimed)

Атомарность claim:

UPDATE quests SET reward_pool_claimed = reward_pool_claimed + 1
WHERE id = :questId AND reward_pool_claimed < reward_pool

Если rows = 0POOL_EXHAUSTED (409). PostgreSQL row-level locking гарантирует, что при конкурентных claim-ах только один получит последний слот.

Cache Invalidation

При claim pool-квеста кэш активных квестов инвалидируется (CachePrefixes.activeQuests), чтобы poolRemaining обновился для других пользователей. Обычные claim-ы не трогают кэш. Thundering herd не проблема — cache-aside pattern: первый miss заполняет кэш.

5. Unique Quests

Квесты с isUnique: true можно получить награду только один раз.

  • Двойная защита: По telegramId И по steamId (если привязан)
  • Запись в ClaimedUniqueQuest при claim
  • Запись в UserQuestExclusion — исключает квест из ротации для пользователя
  • При повторном claim — статус синхронизируется (CLAIMED), но награда не выдается
Multi-account Protection

Защита работает в два уровня:

  1. По telegramId — основная проверка
  2. По steamId — дополнительная (если Steam привязан к нескольким Telegram аккаунтам)

6. RUST Achievements Integration

Прогресс RUST достижений обновляется непосредственно в webhook-обработчиках при получении игровых событий от Rust сервера. Это обеспечивает реальное отслеживание активности игрока.

Логика:

  1. Rust сервер отправляет webhook с игровым событием (например, KILL_ANIMAL)
  2. Webhook handler обрабатывает событие и обновляет прогресс квеста
  3. В том же обработчике обновляется прогресс RUST достижений
  4. Все RUST достижения с подходящими условиями получают прогресс

Пример:

Квест "Убей 100 медведей" (targetProgress=100) выполнен

Подходящие достижения получат +100:
→ "Выполни квесты на 500 убийств медведей" (rustKillAnimalType=BEAR)
→ "Выполни квесты на 1000 убийств животных" (rustEventType=KILL_ANIMAL)
→ "Выполни 100 любых Rust квестов" (conditions=null)
Переменный прогресс

RUST достижения накапливают прогресс по значению quest.targetProgress, а не фиксированный +1 как другие категории.

Это позволяет создавать масштабные долгосрочные достижения: "Выполни квесты на 10000 убийств" выполнится после ~100 квестов "Убей 100 врагов".

Обработка в webhook handler

Прогресс достижений обновляется непосредственно в webhook-обработчиках (см. webhook-handlers.service.ts — метод updateAchievementProgress). Если обновление достижения упадёт с ошибкой, основная обработка webhook события не блокируется.

Полная документация: Achievements - RUST Integration

Reward Types

ТипОписаниеПоле
SCRAPОсновная валютаamount
XPОпыт для прокачки уровняamount
STREAK_POINTSВалюта лояльности (SP)amount
ITEMПредмет в инвентарьitemId
CASEКупон на бесплатное открытие кейсаcaseId

Reward API Response

API возвращает расширенную информацию о награде для визуального отображения:

ПолеТипОписание
itemImageUrlstring?URL изображения предмета
itemTypeenum?Тип предмета: SKIN, BLUEPRINT, FRAGMENT, RESOURCE, BUFF
itemTierenum?Тир предмета: TIER_0TIER_5
targetSkinImageUrlstring?URL изображения базового скина (для BLUEPRINT/FRAGMENT)
caseImageUrlstring?URL изображения кейса
Composite Icons

Для типов BLUEPRINT и FRAGMENT фронтенд отображает композитные иконки: базовое изображение скина (targetSkinImageUrl) с оверлеем значка типа (blueprintbase.webp или blueprint_{tier}.webp).

Difficulty Levels (Сложность)

Опциональное поле difficulty (1-3) позволяет указать уровень сложности квеста:

ЗначениеУровеньЦвет badge
1ЛегкийЗелёный
2СреднийЖёлтый
3СложныйКрасный

UI отображение:

  • TMA (QuestCard): DifficultyBadge рядом с заголовком квеста
  • Admin (QuestTable): Колонка "Сложность" с цветным badge
  • Admin (QuestForm): Select "Уровень сложности" в секции "Сложность"
Переиспользуемый компонент

DifficultyBadge используется как в квестах, так и в достижениях. Компонент поддерживает размеры sm и md.

Quest Hints (Подсказки)

Опциональное поле hint (до 300 символов) позволяет добавить подсказку к квесту:

  • В админке: Textarea в форме создания/редактирования квеста
  • В TMA: Иконка ⓘ рядом с описанием (показывается только если hint заполнен)
  • При клике: Модалка в glass morphism стиле с текстом подсказки и кнопкой "Понятно"
Когда использовать hint

Подсказки полезны для квестов со сложными условиями или неочевидными способами выполнения. Например: "Откройте раздел 'Рулетка' в меню" для квеста на spin.

Rust квесты: автозаполнение подсказки

При создании нового Rust квеста в админке поле hint автоматически заполняется текстом: "💡 Напиши /goloot на сервере чтобы начать трекинг прогресса".

Почему: Игрок, который взял Rust квест будучи уже на сервере, не получит прогресс до следующего TIME_UPDATE webhook (0-5 минут). Команда /goloot заставляет плагин синхронизироваться немедленно.

Admin workflow: Подсказка заполняется автоматически при выборе категории RUST, но админ может отредактировать или очистить текст по необходимости.

Admin UI: Rust Item Picker

Модальное окно выбора предметов для RUST квестов (LOOT_ITEM, ITEM_CRAFTED).

Возможности:

  • 933 предмета из rustclash.com вместо ранних 258
  • 14 категорий в виде горизонтальных табов: Weapons, Construction, Items, Resources, Attire, Tools, Medical, Food, Ammo, Traps, Misc, Components, Electrical, Fun
  • Hybrid filtering: Глобальный поиск по всем предметам или фильтрация по активной категории
  • English names: Все названия на английском (из официальной документации Rust)
  • Lazy loading: Изображения загружаются по мере прокрутки
  • Custom shortname: Возможность ввести shortname вручную (для редких предметов)

UI Flow:

  1. Админ открывает форму создания RUST квеста (LOOT_ITEM или CRAFT)
  2. Клик на поле "Item Shortname" → открывается ItemSelectModal
  3. Выбор категории из табов или использование глобального поиска
  4. Клик на предмет → превью с названием и shortname
  5. Подтверждение → shortname заполняется в форму квеста

Технические детали:

  • Каталог: admin/src/constants/rust-item-catalog.ts
  • Компонент: admin/src/components/quests/ItemSelectModal.tsx
  • CDN изображений: https://wiki.rustclash.com/img/items40/{shortname}.png
  • Fallback: Placeholder "?" при ошибке загрузки изображения
Backward Compatibility

Старый каталог (258 items) полностью заменён новым (933 items). Legacy функции в rust-loot.ts обеспечивают совместимость с существующим кодом через @deprecated обёртки.

Admin UI: Harvest Picker

Модальное окно выбора культур для RUST квестов (FARMING_HARVEST).

Каталог культур (10 штук):

ГруппаShortnameНазвание
ОвощиcornКукуруза
ОвощиpumpkinТыква
ОвощиpotatoКартофель
ОвощиhempКонопля
Ягодыred.berryКрасная ягода
Ягодыblue.berryСиняя ягода
Ягодыgreen.berryЗелёная ягода
Ягодыwhite.berryБелая ягода
Ягодыyellow.berryЖёлтая ягода
Ягодыblack.berryЧёрная ягода

Групповые цели:

  • ANY — любой урожай (засчитывается сбор любого)
  • BERRY — любая ягода (любая из 6 видов)

UI Flow:

  1. Админ создаёт RUST квест с типом FARMING_HARVEST
  2. Клик на поле "Тип урожая" → открывается HarvestSelectModal
  3. Фильтрация по типу: Все | Овощи | Ягоды + поиск по названию
  4. Выбор групповой цели (ANY/BERRY) или конкретной культуры
  5. Подтверждение → shortname заполняется в форму квеста

Технические детали:

  • Каталог: admin/src/constants/rust-harvest.ts
  • Компонент: admin/src/components/quests/HarvestSelectModal.tsx
  • CDN изображений: ${STATIC_URL}/images/items/rust/Food/{shortname}.png
  • Fallback: Placeholder при ошибке загрузки изображения

Smart Quest Generation (Blueprints)

Сезонные квесты генерируются автоматически из code-defined блюпринтов. Каждый блюпринт описывает шаблон генерации с creative русскоязычными названиями, Russian pluralization и условиями.

Блюпринты: quest-blueprints.ts (38 шт.)

Генератор: season-quest-generator.service.ts

Blueprint Matrix

CASES (3 блюпринта) — действия открытия:

IDTypeModeTargetDiff
cases-open-any-dailyDAILYgeneric5–15 (×5)Easy
cases-open-specific-dailyDAILYper-case1–3 (×1)Medium
cases-open-specific-weeklyWEEKLYper-case3–10 (×1)Medium

QUIZ (3 блюпринта):

IDTypeModeTargetDiff
quiz-any-dailyDAILYgeneric1 (fixed)Easy
quiz-any-weeklyWEEKLYgeneric5 (fixed)Easy
quiz-any-permanentPERMANENTgeneric⌈seasonDays × 0.5⌉ (динамически)Medium
Quiz Quests = Generic Only

Quiz-квесты используют только generic режим ("ответь на любой квиз"). Специфичные задания по категориям/предметам/подкатегориям будут реализованы как Achievement templates — долгосрочные цели, для которых не нужна координация с публикацией квизов.

RECYCLE (8 блюпринтов):

IDTypeModeTargetDiff
recycle-any-dailyDAILYgeneric1–5 (×1)Easy
recycle-blueprint-dailyDAILYgeneric1–3 (×1)Easy
recycle-fragment-dailyDAILYgeneric1–3 (×1)Easy
recycle-tier-dailyDAILYgeneric1–2 (×1)Medium
recycle-tier-weeklyWEEKLYgeneric1–5 (×1)Medium
recycle-blueprint-tier-weeklyWEEKLYgeneric2–5 (×1)Medium
recycle-fragment-tier-weeklyWEEKLYgeneric2–5 (×1)Medium
recycle-item-weeklyWEEKLYper-item1 (fixed)Hard

COLLECTION (10 блюпринтов) — content-aware результаты из кейсов + рулеток:

IDTypeModeTargetDiffУсловия
collection-resource-dailyDAILYgeneric3–10 (×1)EasyitemType: RESOURCE
collection-fragment-dailyDAILYgeneric1–3 (×1)MediumitemType: FRAGMENT
collection-tier-dailyDAILYgeneric1–3 (×1)MediumitemTier: 2, rewardType: ITEM
collection-item-dailyDAILYper-item1 (fixed)EasyitemId: {id}
collection-tier-weeklyWEEKLYgeneric1–5 (×1)MediumitemTier: 2
collection-blueprint-weeklyWEEKLYgeneric1–3 (×1)MediumitemType: BLUEPRINT
collection-item-weeklyWEEKLYper-item1 (fixed)MediumitemId: {id}
collection-resource-weeklyWEEKLYgeneric15–50 (×5)EasyitemType: RESOURCE
collection-tier-permanentPERMANENTgeneric1–5 (×1)1→3itemTier: 3
collection-item-permanentPERMANENTper-item1 (fixed)HarditemId: {id}, isUnique
Content-Aware генерация

COLLECTION блюпринты анализируют реальный контент сезона (предметы в кейсах и рулетках). Если в сезоне нет ресурсов — collection-resource-* блюпринты не генерируются.

SOCIAL (2 блюпринта):

IDTypeModeTargetDiff
social-referral-permanentPERMANENTgeneric1–5 (×1)Medium
social-subscriptionPERMANENTgeneric1 (fixed)Easy
Social Subscription

social-subscription — одноразовый квест (isUnique). Проверяет подписку на Telegram канал через Bot API getChatMember. Канал определяется env var TELEGRAM_CHANNEL_USERNAME.

SPECIAL (1 блюпринт):

IDTypeModeTargetDiff
special-level-permanentPERMANENTgeneric[3, 5, 10] (explicit)1→2→3

STREAK (2 блюпринта):

IDTypeModeTargetDiff
special-streak-weeklyWEEKLYgeneric3–7 (×1)Medium
streak-milestonePERMANENTgeneric7/14/30 (explicit)1→3

PROGRESSION (3 блюпринта) — централизованный XP tracking:

IDTypeModeTargetDiff
xp-earn-dailyDAILYgeneric100–500 (×50)Easy
xp-earn-weeklyWEEKLYgeneric500–2000 (×250)Easy
xp-earn-permanentPERMANENTgeneric5000–20000 (×2500)Hard
Source-Based Filtering

XP и Scrap квесты могут иметь опциональный фильтр по источнику (cases, quizzes, quests и т.д.). При генерации источник выбирается случайно — ~33% шанс что квест будет "из любого источника".

ECONOMY (6 блюпринтов) — централизованный Scrap tracking:

IDTypeModeTargetDiff
scrap-earn-dailyDAILYgeneric50–300 (×50)Easy
scrap-earn-weeklyWEEKLYgeneric500–2000 (×250)Easy
scrap-earn-permanentPERMANENTgeneric5000–30000 (×5000)Hard
scrap-spend-dailyDAILYgeneric50–300 (×50)Easy
scrap-spend-weeklyWEEKLYgeneric300–1500 (×150)Easy
scrap-spend-permanentPERMANENTgeneric3000–15000 (×3000)Hard

Generation Modes

РежимОписаниеВарианты
genericБез привязки к контентуДо 3 (DAILY/WEEKLY), 1 (PERMANENT)
per-caseПо 1 квесту на каждый кейс сезона{caseName} в названии
per-itemПо 1 квесту на предмет из пула (по difficulty){itemName} в названии
Roman Numerals

Когда блюпринт генерирует >1 варианта (generic mode), к названию добавляются римские цифры: "Эрудит I", "Эрудит II", "Эрудит III".

Content-Aware Generation

Генератор анализирует реальный контент сезона через analyzeSeasonContent():

  1. Cases + Spins — все предметы и награды из привязанных кейсов и рулеток
  2. Item deduplication — один предмет может быть в нескольких кейсах; берётся лучший dropChance
  3. Difficulty mapping — dropChance → difficulty: ≥5% = Easy(1), 1-5% = Medium(2), <1% = Hard(3)
  4. Items by type — группировка по itemType (SKIN, BLUEPRINT, FRAGMENT, RESOURCE, BUFF)
  5. Scrap analysis — минимальный/средний/максимальный скрап из наград

COLLECTION per-item блюпринты получают пул предметов по difficulty (DAILY → Easy, WEEKLY → Medium, PERMANENT → Hard).

Placeholder Rewards

Генерируемые квесты получают SCRAP награды по difficulty:

DifficultyReward
Easy (1)50 Scrap
Medium (2)100 Scrap
Hard (3)150 Scrap

Админ настраивает финальные награды в Step 6 Setup Wizard после генерации.

Protection

ДействиеRate LimitAuthValidation
Get questsgeneral (100/min)TelegramGetUserQuestsSchema
Get quest by IDgeneral (100/min)TelegramGetUserQuestByIdSchema
Claim rewardachievementClaim (15/min)Telegram + Active SeasonClaimQuestRewardSchema
Check questgeneral (100/min)Telegram + Active SeasonCheckQuestSchema
Start Rust questgeneral (100/min)Telegram + Active SeasonStartRustQuestSchema
Dismiss pool questgeneral (100/min)Telegram + Active SeasonDismissQuestSchema
Детали реализации

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

Edge Cases

Что видит пользователь (UI):

СитуацияUI поведение
RUST квест без стартаКнопка "Начать квест", статус AVAILABLE
Внутренний квест без прогресса"Ожидает прогресса", статус PENDING
Квест с прогрессом"Выполняется...", показан прогресс X/Y
Квест выполненКнопка "Забрать" активна, анимация готовности
Награда полученаСтатус CLAIMED, карточка скрыта
Claim награды (UI)Fade-out анимация 300ms → карточка исчезает из списка
После claimКвесты остаются на месте (не "прыгают" вниз)
Claim последнего квеста на страницеАвтоматический переход на предыдущую страницу
Daily reset прошёлПрогресс сброшен, квест снова IN_PROGRESS
Уникальный уже полученПри claim — статус синхронизируется без ошибки
Pool quest: "Осталось: N"Badge рядом с timeLeft (amber, для remaining > 0)
Pool exhausted, юзер без прогресса"Награды закончились" (серый текст, без кнопки)
Pool exhausted, юзер с прогрессомКнопка "Понятно" → dismiss (убирает квест)
Pool exhausted, юзер COMPLETEDClaim → POOL_EXHAUSTED (409), кнопка "Понятно"
2 concurrent claims, 1 slotAtomic SQL: один получает reward, второй → POOL_EXHAUSTED (409)
Admin увеличивает poolБезопасно: remaining пересчитывается автоматически
Admin уменьшает ниже claimedValidation error: "Нельзя ниже N"
Admin убирает pool (null)Квест становится безлимитным, rewardPoolClaimed остаётся для аналитики
Pool quest + DAILY/WEEKLYValidation error: "Pool quests cannot be DAILY or WEEKLY"
Cache staleness (120s TTL)Допустимо для display. Claim проверяет real-time через atomic SQL
Квест с подсказкойИконка ⓘ рядом с описанием → модалка с hint при клике
Квест без подсказкиИконка ⓘ не отображается
Квест с difficultyDifficultyBadge рядом с заголовком (1=зелёный, 2=жёлтый, 3=красный)
Квест без difficultyDifficultyBadge не отображается

Frontend Polling (автообновление списка квестов):

Состояние спискаИнтервалПричина
Пустой список (нет квестов)30 секПодхватить квесты при активации нового сезона
Есть IN_PROGRESS квесты15 секБыстрое обновление прогресса от webhook-ов
Все квесты COMPLETED / нет IN_PROGRESS60 секЭкономия трафика при отсутствии активности
Почему пустой список не останавливает polling

Ранее при пустом списке квестов polling полностью останавливался. Это приводило к проблеме: при активации нового сезона пользователи с пустым списком никогда не получали новые квесты без ручного обновления страницы. Сейчас polling продолжается с интервалом 30 секунд, что позволяет автоматически подхватить квесты из нового сезона.

Optimistic Updates (мгновенный отклик UI):

Claim и Start используют optimistic updates через React Query — UI обновляется мгновенно, не дожидаясь ответа сервера:

ДействиеOptimistic UpdateRollback при ошибке
Claim наградыКвест удаляется из кеша, запускается fade-outКвест восстанавливается из snapshot
Start Rust квестаСтатус AVAILABLEIN_PROGRESSСтатус откатывается к snapshot
QUESTS_QUERY_KEY — единый ключ кеша

Все hooks (useQuests, useQuestClaim, useQuestStart, useQuestManagement, bootstrapHydration) используют единый QUESTS_QUERY_KEY из useQuests.ts. Это критично для корректной работы optimistic updates — getQueryData и setQueryData требуют точное совпадение ключа (в отличие от invalidateQueries, который работает по partial match).

Отображение наград (RewardIcon):

Тип наградыОтображение
SCRAP / XP / SPКомпонент Currency с иконкой и суммой
ITEM (SKIN/RESOURCE)Изображение предмета 48×48px
ITEM (BLUEPRINT)Изображение targetSkin + оверлей blueprintbase.webp
ITEM (FRAGMENT)Изображение targetSkin + оверлей blueprint_{tier}.webp
ITEM (BUFF)Изображение или иконка Zap (fallback)
CASEИзображение кейса 48×48px
Количество > 1Badge "×N" в правом нижнем углу
Нет изображенияИконка Package (fallback)
Backend Error Codes (для API/тестов)
КодHTTPСообщение пользователю
QUEST_NOT_FOUND404"Квест не найден"
NOT_COMPLETED400"Квест еще не выполнен"
ALREADY_CLAIMED400"Награда уже получена"
UNIQUE_ALREADY_CLAIMED400"Эта награда уже была получена ранее"
POOL_EXHAUSTED409"Награды закончились"
REWARD_NOT_FOUND500"Награда не найдена"
ITEM_NOT_FOUND500"Предмет награды не найден"
CASE_NOT_FOUND500"Кейс награды не найден"

3. ADR (Architectural Decisions)

Почему Shared Progress, а не отдельный трекинг?

Проблема: При каждом действии нужно засчитывать прогресс в нескольких квестах одновременно.

Решение: Паттерн Shared Progress — incrementCategoryProgress() находит ВСЕ подходящие квесты и увеличивает прогресс в рамках одной транзакции.

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

  • Event sourcing с отложенной обработкой — сложно, задержки в UI
  • Отдельные счетчики на каждый квест — дублирование логики, race conditions

Последствия: Простота интеграции (один вызов из любого домена), атомарность, но нужна транзакция на запись.

Почему Lazy Reset, а не cron?

Проблема: Нужно сбрасывать прогресс DAILY/WEEKLY квестов без блокировки на всю базу.

Решение: Lazy Reset — сброс при первом обращении пользователя. Проверка lastResetAt vs текущего периода.

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

  • Cron job с массовым UPDATE — блокировка таблицы, пиковая нагрузка в полночь
  • TTL в Redis — сложность консистентности с PostgreSQL

Последствия: Равномерная нагрузка, но первый запрос дня чуть медленнее.

Почему детерминированная ротация?

Проблема: При случайном выборе квестов разные пользователи видят разные задания — нельзя обсуждать в сообществе.

Решение: Seeded shuffle на основе даты — все видят одинаковые квесты в один день.

Последствия: Единый игровой опыт, возможность обсуждения "квеста дня", но меньше персонализации.

Почему централизованный ScrapService?

Проблема: Scrap начислялся/списывался в 13+ местах (cases, quizzes, quests, spins, referrals и т.д.) напрямую через Prisma. Невозможно было отслеживать экономику через квесты без единой точки входа.

Решение: Создан ScrapService (backend/src/domains/users/services/scrap.service.ts) — аналог XPService. Все операции со Scrap проходят через addScrap() / spendScrap() / addScrapWithBuff().

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

  • Event-based tracking — сложнее, чем прямой вызов; не оправдано для текущего масштаба

Последствия: Единая точка для статистики (scrapFrom{Source}, scrapSpentOn{Source}), сезонных метрик и quest progress hooks.

Почему "Особые" UI grouping для ECONOMY и PROGRESSION?

Проблема: Новые категории ECONOMY и PROGRESSION нужно показывать пользователю, но добавлять отдельные фильтры в UI — перегрузка интерфейса.

Решение: Frontend фильтр "Особые" объединяет SPECIAL + STREAK + ECONOMY + PROGRESSION. Пользователь не различает между "заработай XP" (PROGRESSION), "достигни уровня" (SPECIAL) и "держи стрик" (STREAK).

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

  • Отдельные фильтры для каждой категории — слишком много UI-элементов, пользователи путаются

Последствия: Чистый UI с минимальным количеством фильтров. Категории различаются только на уровне backend.

Почему Admin/Promo исключены из quest tracking?

Проблема: Admin grants (/admin/grant) и promo-коды начисляют XP/Scrap. Если засчитывать их в quest progress — админ может "выполнять" квесты пользователям.

Решение: Admin grants и promo-code rewards исключены из quest progress tracking. Только "органические" источники (cases, quizzes, spins, referrals, achievements, quests, salvage) засчитываются.

Последствия: Честная система прогресса; промо-акции не влияют на квесты.

Почему progressType discriminator?

Проблема: ECONOMY содержит два типа квестов: "заработай Scrap" и "потрать Scrap". Без дискриминатора один вызов incrementCategoryProgress('ECONOMY', ...) засчитал бы прогресс в оба типа одновременно.

Решение: Поле progressType в QuestConditions (xp-earn | scrap-earn | scrap-spend) обеспечивает точное matching. matchesConditions() использует exact match для progressType.

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

  • Отдельные категории ECONOMY_EARN / ECONOMY_SPEND — раздувает enum, усложняет UI фильтры

Последствия: Одна категория ECONOMY с подтипами, чистое разделение earn/spend без cross-matching.

Почему COLLECTION scrap перенесён в ECONOMY?

Проблема: В COLLECTION были 3 scrap-related блюпринта ("Набери N скрапа"), которые не имели отношения к коллекционированию предметов.

Решение: Scrap-related блюпринты удалены из COLLECTION (PLAN_03). COLLECTION остаётся чисто item-related (10 блюпринтов). Scrap tracking переехал в ECONOMY с более детальными conditions (по source).

Последствия: Чистое разделение ответственности: COLLECTION = предметы, ECONOMY = валюта. DRY — нет дублирования tracking логики.

Почему rewardPool + rewardPoolClaimed, а не remaining?

Проблема: Нужен счётчик оставшихся наград в pool-квестах. Один столбец remaining или два (rewardPool + rewardPoolClaimed)?

Решение: Два поля: rewardPool (конфигурация, устанавливает админ) + rewardPoolClaimed (атомарный счётчик). remaining = rewardPool - rewardPoolClaimed — вычисляемое.

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

  • Одно поле remaining с декрементом — админ не может безопасно менять размер пула (нужно пересчитывать вручную)

Последствия: Админ может менять rewardPool без пересчёта claimed. remaining всегда актуален.

Почему Raw SQL для atomic pool decrement?

Проблема: При конкурентных claim-ах нужна атомарная проверка "claimed < pool" и инкремент.

Решение: Raw SQL UPDATE ... SET claimed = claimed + 1 WHERE claimed < pool внутри транзакции. PostgreSQL row-level locking гарантирует, что второй concurrent UPDATE ждёт commit первого и re-evaluates condition.

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

  • Prisma updateMany — не поддерживает WHERE column_a < column_b
  • Optimistic locking с version — дополнительная сложность, retry loop

Последствия: Гарантированная атомарность без race conditions. Один raw SQL call.

Почему pool-квесты не DAILY/WEEKLY?

Проблема: Pool-квесты с ограниченным количеством наград + DAILY/WEEKLY reset — конфликт логики.

Решение: Validation error при попытке создать pool-квест с типом DAILY или WEEKLY. Только PERMANENT и EVENT допустимы.

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

  • Разрешить DAILY/WEEKLY с пулом — непредсказуемое поведение при reset (прогресс сбрасывается, а пул уже исчерпан)

Последствия: Простая и предсказуемая модель: pool = конечный ресурс, без temporal reset.

Quiz-Quest Coordination: почему quiz-квесты только generic?

Проблема: Две независимые системы не координированы:

  1. Публикация квизов — случайный выбор 3 квизов/день из неопубликованного пула, отправка в Telegram
  2. Ротация квестов — детерминированный выбор дневных/недельных квестов из пула

Специфичные quiz-квесты (по категории, подкатегории, slug, entityType) были бы часто невыполнимы: если дневной квест требует квиз про оружие, а сегодня опубликованы квизы про еду — квест невыполним.

Решение: Quiz-квесты используют только generic режим ("ответь на любой квиз") с таргетами: daily=1 (fixed), weekly=5 (fixed), permanent=1–100 (dynamic). Специфичные quiz-задания (по категории/подкатегории/slug/entityType) будут реализованы как Achievement templates — долгосрочные цели на весь сезон, для которых проблема координации не критична.

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

  • Специфичные quiz-квесты как PERMANENT — по-прежнему создают неудобства (ждать нужный квиз) и загромождают пул квестов
  • Координация публикации с ротацией квестов — сложная связность, breaking existing architecture
  • Quiz Catalog в TMA (все квизы доступны без публикации) — меняет бизнес-модель, снижает ценность Telegram-канала

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

  • Чистое разделение: квесты = широкие задания, достижения = точечные цели
  • Нет проблемы с координацией — любой опубликованный квиз засчитывается
  • PERMANENT_POOL_MAX остаётся 40 (PERMANENT_POOL_MIN + 10, где MIN = max(20, blueprintCount × 3) — quiz permanent не раздувает пул)

4. Architecture

Flow: Прохождение квиза → Прогресс квеста

Flow: Claim награды

Key Components

КомпонентПутьОписание
QuestServicebackend/src/domains/quests/services/quest.service.tsПолучение квестов с прогрессом, ротация
QuestProgressServicebackend/src/domains/quests/services/quest-progress.service.tsShared Progress — увеличение прогресса
QuestRewardServicebackend/src/domains/quests/services/quest-reward.service.tsВыдача наград
QuestResetServicebackend/src/domains/quests/services/quest-reset.service.tsLazy Reset для DAILY/WEEKLY
QuestRotationServicebackend/src/domains/quests/services/quest-rotation.service.tsStateless deterministic ротация квестов
QuestAdminServicebackend/src/domains/quests/services/quest-admin.service.tsAdmin CRUD операции с квестами
ScrapServicebackend/src/domains/users/services/scrap.service.tsЦентрализованный earn/spend Scrap с quest progress hooks
QuestImportServicebackend/src/domains/quests/services/quest-import.service.tsМассовый импорт квестов из JSON (dry run + create)
TelegramSubscriptionServicebackend/src/domains/quests/services/telegram-subscription.service.tsПроверка подписки через Telegram API
QuestControllerbackend/src/domains/quests/controllers/user-quest.controller.tsUser API контроллер
QuestAdminControllerbackend/src/domains/quests/controllers/admin-quest.controller.tsAdmin API контроллер
Routesbackend/src/domains/quests/routes/user-quests.routes.tsUser API роуты
Admin Routesbackend/src/domains/quests/routes/admin-quests.routes.tsAdmin API роуты
Schemasbackend/src/domains/quests/schemas/quest-base.schemas.ts, user-quests.schemas.ts, admin-quests.schemas.tsВалидация запросов
useQuestsfrontend/src/hooks/useQuests.tsReact Query hook — загрузка квестов, polling, экспорт QUESTS_QUERY_KEY
useQuestClaimfrontend/src/hooks/useQuestClaim.tsMutation hook — claim награды с optimistic update
useQuestStartfrontend/src/hooks/useQuestStart.tsMutation hook — старт Rust квеста с optimistic update
useQuestManagementfrontend/src/components/screens/HomeScreen/hooks/useQuestManagement.tsКомпозитный hook — фильтры, пагинация, derived state
HarvestSelectModaladmin/src/components/quests/HarvestSelectModal.tsxAdmin: визуальный picker урожая для FARMING_HARVEST
rust-harvest.tsadmin/src/constants/rust-harvest.tsКаталог культур (10 шт), группы, хелперы

5. Database Schema

Models

МодельОписаниеКлючевые поля
QuestКонфигурация квестаtitle, description, hint, type, category, targetProgress, difficulty, rewardId, conditions, isUnique, isPriority, rewardPool, rewardPoolClaimed, rustServerId, rustEventType, rustParams
UserQuestПрогресс пользователяuserId, questId, currentProgress, status, lastResetAt, rewardSnapshot
ClaimedUniqueQuestУникальные награды по telegramId/steamIdtelegramId, steamId, questId
RewardНаграда (общая модель)type, amount, amountMax, itemId, caseId
UserQuestExclusionПерсональные исключения (isUnique)telegramId, steamId, questId, seasonId, reason
UserFreeCaseOpensСчетчик бесплатных открытий кейсов (награда типа CASE)userId, caseId, count, createdAt
rustParams — консолидированные Rust параметры

Rust квесты хранят конфигурацию (целевые ресурсы, типы животных, shortnames предметов и т.д.) в одном JSON поле rustParams вместо 37 отдельных колонок. Внутри rustParams поля без rust prefix: fishType, targetMinutes, craftItemShortname.

API response остаётся FLAT с rust prefix для совместимости с фронтендом. Подробнее: Rust Integration — ADR: rustParams

Reward Audit (rewardSnapshot)

При claim награды в UserQuest.rewardSnapshot сохраняется JSON снимок:

{
"type": "ITEM",
"amount": 1,
"itemId": "item-123",
"itemName": "AK-47 Blueprint",
"itemTier": "TIER_3"
}
Для чего нужен rewardSnapshot

Snapshot фиксирует выданную награду на момент claim. Если квест или награда изменится — аудит сохранит оригинальные значения для истории операций пользователя.

Quest Statuses (Backend)

СтатусОписание
IN_PROGRESSАктивен, прогресс засчитывается
COMPLETEDВыполнен, ожидает claim
CLAIMEDНаграда получена

Quest UI Statuses (Frontend)

Frontend расширяет backend статусы дополнительными состояниями для UX:

UI СтатусУсловиеКнопка / Отображение
AVAILABLERUST квест без startedAt"Начать квест"
PENDINGНе-RUST квест с currentProgress = 0"Ожидает прогресса"
IN_PROGRESScurrentProgress > 0 и < targetProgress"Выполняется..."
COMPLETEDcurrentProgress >= targetProgress, не claimed"Забрать" (активна)
POOL_EXHAUSTEDpoolRemaining === 0 и статус !== CLAIMED"Награды закончились" / "Понятно" (dismiss)
CLAIMEDНаграда полученаНе отображается
Shared Progress vs Explicit Start

RUST квесты требуют явного старта через "Начать квест" — это создаёт Steam сессию и проверяет привязку.

Внутренние квесты (QUIZ, CASES, SOCIAL, etc.) используют Shared Progress — прогресс засчитывается автоматически при первом действии. Статус PENDING показывает, что квест ждёт первого действия пользователя.

Сортировка UI статусов

Квесты сортируются по UI статусу: AVAILABLECOMPLETEDIN_PROGRESSPENDINGPOOL_EXHAUSTEDCLAIMED.

Это гарантирует, что:

  • RUST квесты готовые к старту — первыми
  • Квесты готовые к claim — вторыми
  • Квесты с прогрессом — перед "ожидающими"
  • Исчерпанные pool-квесты — внизу (выше claimed)

Relationships

Key Indexes

@@index([type])              -- Фильтрация по типу (DAILY, WEEKLY, etc.)
@@index([category]) -- Фильтрация по категории (QUIZ, CASES, etc.)
@@index([isActive]) -- Только активные квесты
@@index([isPriority]) -- Сортировка приоритетных первыми
@@index([startAt, endAt]) -- EVENT квесты в диапазоне дат
@@index([rustServerId]) -- Фильтрация Rust квестов по серверу
@@index([isActive, category]) -- Compound index для частой фильтрации активных квестов по категории

6. API Endpoints

МетодЭндпоинтОписаниеDocs
GET/api/questsСписок квестов с прогрессом
GET/api/quests/:idДетали квеста
POST/api/quests/:id/claimПолучение награды
POST/api/quests/:id/checkПроверка выполнения (SOCIAL)
POST/api/quests/:id/startНачать Rust квест
POST/api/quests/:id/dismissDismiss исчерпанного pool-квеста

  • Seasons — квесты привязаны к сезону через SeasonQuest, лимиты ротации в Season model
  • Cases — квесты категории CASES отслеживают открытия кейсов
  • Quizzes — квесты категории QUIZ отслеживают прохождение квизов
  • Achievements — похожая механика с условиями и наградами
  • Streaks — STREAK квесты с условием streakDays
  • Referrals — SOCIAL квесты на приглашение друзей
  • Rust Integration — квесты категории RUST
  • Inventory — куда попадают ITEM награды