Quizzes
1. Summary
Goal: Повысить вовлечённость чтения Telegram канала через автоматическую публикацию квизов сразу после выхода поста. Квиз живёт ~3 часа, затем удаляется — создаёт срочность и мотивирует включить уведомления.
User Value: Возможность заработать Scrap за правильные ответы в комментариях к посту. Ограниченное время создаёт FOMO и причину следить за каналом.
Сейчас: только Scrap. Планируется: XP, SP (не реализовано в коде).
2. Business Logic
Question Types
- SINGLE
- MULTIPLE
Описание: Один правильный ответ из нескольких вариантов.
Требования:
- Ровно 1 правильный ответ (
isCorrect: true) - Минимум 3 неправильных ответа
correctAnswer— строка, совпадающая с текстом правильного варианта
Пример: "Сколько HP у металлической двери?" → "250 HP"
Описание: Несколько правильных ответов. Пользователь не знает, сколько ответов верно — нужно выбрать все правильные.
Требования:
- Минимум 2 правильных ответа (
isCorrect: true) - Минимум 1 неправильный ответ
correctAnswer— массив строк, все должны совпадать с текстами правильных вариантов
Примеры:
- 2/2 split: "Какие ресурсы нужны для Furnace?" → ["Stone", "Wood"] + 2 неверных
- 3/1 split: "Из чего крафтится Metal Door?" → ["Metal Fragments", "Gears", "Wood"] + 1 неверный
Категоризация
Иерархия: Category → Subcategory → Quiz
📁 Category: "Rust"
├── 📂 Subcategory: "HP строений"
│ ├── Quiz: "Сколько HP у металлической двери?"
│ └── Quiz: "Сколько HP у каменной стены?"
└── 📂 Subcategory: "Крафт"
└── Quiz: "Сколько металла нужно для двери?"
Квиз не может быть создан без привязки к подкатегории. Это обеспечивает структурированную организацию контента.
Core Mechanics
Validation Rules:
| Поле | Ограничения |
|---|---|
question | 5-500 символов |
explanation | 10-1000 символов |
options[].text | 1-200 символов |
options | SINGLE: 1 верный + 3 неверных; MULTIPLE: 2+ верных + 1+ неверный |
timeLimit | 10-600 секунд (опционально) |
rewardScrap | 0-10000 (по умолчанию 100) |
source | валидный HTTP/HTTPS URL |
imageUrl | HTTP/HTTPS URL или путь /images/quizzes/* |
Каскадная активация:
При изменении статуса isActive происходит каскадное обновление:
Category (isActive: false)
↳ Subcategory (isActive: false)
↳ Quiz (isActive: false)
При деактивации категории автоматически деактивируются все её подкатегории и квизы.
Auto-Generation (Season Quizzes)
Квизы для сезона генерируются автоматически через QuizGeneratorService — логика портирована из внешнего Node.js скрипта в TypeScript сервис. Поддерживаются два подхода: массовая генерация по категориям и точечная генерация по конкретным предметам.
Data Pipeline:
quizzes/ repo (отдельный git) backend/ (монорепо)
┌─────────────────────────┐ ┌──────────────────────────┐
│ JSON (crawl/parse) │ │ QuizGeneratorService │
│ ↓ │ │ ↓ │
│ migrate.js │ │ SELECT ... FROM items │
│ ├── schema.sql │ ──copy──▶ │ WHERE quiz_eligible=1 │
│ ├── items INSERT │ │ ↓ │
│ ├── QuizEligibility │ │ quiz-templates.json │
│ │ Enricher │ │ ↓ │
│ └── rust_data.db │ │ Generated quizzes → DB │
└─────────────────────────┘ └──────────────────────────┘
Источники данных:
- SQLite (
rust_data.db, ~28MB) — read-only база с предметами Rust (HP, крафт, рейды) - Templates (
quiz-templates.json) — шаблоны вопросов по категориям - Eligibility config (
quiz-eligibility.json) — правила исключения NPC/admin/event предметов
Quiz Eligibility Filtering:
Поле quiz_eligible в таблице items определяет, можно ли генерировать квизы по предмету. Конфиг quizzes/data/quiz-eligibility.json задаёт правила исключения:
| Правило | Тип матчинга | Примеры |
|---|---|---|
| NPC outfits | exact shortname | scientistsuit_heavy, attire.banditguard |
| Frankenstein NPC parts | shortname LIKE | %franken% |
| Admin-only weapons | exact shortname | rocket.launcher.rpg7 |
| Items in development | name LIKE | %РАЗРАБОТКЕ% |
| Unobtainable attire | exact shortname | jumpsuit.suit, hat.gas.mask |
| Promo/streaming items | exact shortname | lumberjack.hoodie, twitchrivalsflag |
| Seasonal event items | exact shortname | gloweyes, gingerbreadsuit |
Enrichment выполняется при каждом migrate.js --force — поле вычисляется из конфига, не хранится в исходных JSON.
Для исключения предмета — добавить shortname в quizzes/data/quiz-eligibility.json и перегенерировать базу (node src/db/migrate.js --force). Код генератора менять не нужно.
Категории:
| Категория | Items | SINGLE | MULTIPLE | Subcategories | Utilization |
|---|---|---|---|---|---|
weapons | 84 | 38 | 7 | 9 | ~85% |
attire | 131 | 31 | 8 | 8 | ~90% |
food | 157 | 24 | 16 | 6 | ~85% |
construction | — | 20 | 6 | — | — |
items | — | 21 | 5 | — | — |
electrical | — | 18 | 6 | — | — |
tools | — | 18 | 6 | — | — |
components | — | 18 | 7 | — | — |
loot_containers | — | 18 | 9 | — | — |
fun | — | 17 | 7 | — | — |
ammo | 40 | 16 | 6 | 4 | ~70% |
resources | — | 13 | 7 | — | — |
misc | — | 13 | 5 | — | — |
traps | — | 12 | 6 | — | — |
scientists | — | 10 | 5 | — | — |
medical | 5 | 9 | 2 | 2 | ~60% |
animals | — | 9 | 7 | — | — |
vehicles | — | 9 | 7 | — | — |
missions | — | 5 | 3 | — | — |
vendors | — | 5 | 3 | — | — |
raid | 345 | 3 | 1 | 2 | ~10% |
Стратегии генерации неправильных ответов (SINGLE):
| Стратегия | Описание |
|---|---|
close_values | Рядом с правильным: offset_percentage (±%), offset_fixed (±N), offset_small |
workbench_levels | Уровни верстаков (0-3) |
text_options | Выбор из текстового пула (контейнеры, локации) |
range_values | Диапазон значений в строковом формате |
MULTIPLE grouping стратегии:
| Стратегия | Описание |
|---|---|
workbench_level | По уровню верстака крафта |
craft_ingredient | По общему ингредиенту крафта |
has_field | По наличию поля (значение > 0 или непустая строка) |
threshold | По числовому порогу (field >= value) |
loot_only | Предметы без крафта (с опциональным whitelist) |
raid_quantity | По количеству рейд-инструмента |
Расчёт сложности: Задаётся в шаблоне (difficulty: easy|medium|hard). Награда: EASY=50, MEDIUM=100, HARD=150 scrap.
Targeted Generation (Item-Level)
Точечная генерация позволяет создавать квизы для конкретных предметов (по shortname) вместо целых категорий. Используется для добавления квизов под конкретные slug-targeted достижения.
Flow (Admin UI — collapsible-секция в Step 5 Wizard):
- Выбор категории → загрузка предметов с подсчётом доступных шаблонов на каждый предмет
- Опциональная фильтрация по подкатегории / поиск по имени
- Выбор предметов (чекбоксы) → отображение максимального количества шаблонов
- Генерация → всегда append-режим (без удаления существующих)
Input: { category, shortnames[], count? }
Алгоритм:
- Загрузить предметы из SQLite по shortnames
- Для каждого предмета проверить все шаблоны категории (SINGLE + MULTIPLE)
- Исключить дубли по
questionтекста (с существующими квизами сезона) - Записать в БД + инкрементировать
SeasonContentBudget
Result: { generatedCount, byItem: Record<slug, count>, byDifficulty, errors[], skipped[] }
Skip Diagnostics:
При targeted-генерации для каждого пропущенного шаблона собирается детальная диагностика — почему квиз не удалось сгенерировать. В UI это блок "N шаблонов пропущено" с раскрываемыми деталями.
| Поле | Описание | Пример |
|---|---|---|
reason | Причина пропуска с контекстом данных | "Недостаточно несовместимых оружий (compatible: 24, incompatible: 2)" |
detail.sourceValue | Состояние данных при отказе | "compatible: 24, incompatible: 2" |
detail.correctAnswer | Правильный ответ (если удалось определить) | "Штурмовая винтовка" |
detail.wrongAnswers | Неправильные варианты (если частично сгенерированы) | ["Дробовик", "Пистолет"] |
detail.strategy | Стратегия генерации неправильных ответов | "close_values" |
Skip diagnostics доступна только при точечной генерации (generateForItems). При массовой генерации для сезона (generateForSeason) пропущенные шаблоны не трекаются — система просто генерирует всё, что может.
Quiz Availability Check
Endpoint для проверки наличия квизов по slug в активном сезоне. Используется в Achievement Editor (ConditionsEditor) для отображения предупреждения, когда slug-targeted достижение (категория QUIZ) ссылается на предмет, для которого недостаточно квизов.
UI: Жёлтый warning badge в ConditionsEditor при available < targetProgress, зелёный check при достаточном количестве.
Формат шаблона (quiz-templates.json)
{
"category": "weapons",
"question": "Сколько HP у {item.name}?",
"answerField": "hp",
"answerType": "direct_fact",
"wrongStrategy": "percentage_offset",
"difficultyBase": 2,
"conditions": [{ "field": "hp", "op": ">", "value": 0 }]
}
answerField— поле из SQLite записи предметаconditions— фильтр предметов, подходящих для шаблонаdifficultyBase— базовый балл сложности шаблона
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| CRUD квизов | adminApiLimiter (50/min) | Admin JWT | CreateQuizSchema, UpdateQuizSchema |
| Get stats | adminApiLimiter (50/min) | Admin JWT | GetQuizStatsSchema |
См. Security Matrix для полного обзора защит.
Edge Cases
| Ситуация | UI/Backend поведение |
|---|---|
| ❌ Некорректный questionType | QuizValidationError с указанием поля |
| ❌ correctAnswer не совпадает с options | QuizValidationError: "correctAnswer must match the text of the correct option" |
| ❌ Недостаточно вариантов (SINGLE) | QuizValidationError: "Minimum 4 options required" |
| ❌ Недостаточно вариантов (MULTIPLE) | QuizValidationError: "MULTIPLE type requires at least 2 correct answers" / "at least 1 incorrect answer" |
| ❌ Категория не найдена | HTTP 400: "Category not found" |
| ❌ Подкатегория не указана | QuizValidationError: "Подкатегория обязательна для создания квиза" |
| 🔄 Удаление категории с квизами | HTTP 400: "Невозможно удалить категорию: есть связанные квизы" |
| 📊 Запрос статистики | Кеш 5 минут, fallback на пересчёт |
| ✅ Review checkbox | localStorage-чекбокс в Season Wizard Step 5. Проверенные квизы: opacity-60, таб-бейдж {reviewed}/{total}. Состояние через useSeasonReview хук |
| ✏️ Save из EditQuizModal | НЕ ставит review автоматически — редактирование !== ревью |
3. ADR (Architectural Decisions)
Почему два типа вопросов (SINGLE/MULTIPLE)?
Проблема: Нужна гибкость для разных типов контента — простые факты (один ответ) и комплексные знания (несколько ответов).
Решение: Enum QuizQuestionType с жёсткой валидацией соответствия correctAnswer типу вопроса.
Альтернативы (отклонены):
- Только SINGLE — ограничивает типы вопросов
- Свободный ввод — сложная валидация, плохой UX
Последствия: Код валидации дублируется для каждого типа, но обеспечивает строгую консистентность данных.
Почему обязательная подкатегория?
Проблема: Без структуры сложно фильтровать и организовывать большое количество квизов.
Решение: Subcategory обязательна. При генерации квизов подкатегория назначается автоматически на основе данных предмета.
Альтернативы (отклонены):
- Теги вместо иерархии — сложнее управлять, нет чёткой структуры
- Опциональная подкатегория — приведёт к "свалке" квизов без категоризации
Последствия: Больше работы при создании контента, но лучшая организация.
Почему template-based генерация вместо ручного создания?
Проблема: Каждый сезон требует ~270 квизов (90 дней × 3 сложности). Ручное создание — это 270 × 5 мин = 22.5 часов работы контент-менеджера.
Решение: QuizGeneratorService генерирует квизы из шаблонов + SQLite базы данных Rust. Админ выбирает категории и количество, генерация занимает ~2 секунды.
Альтернативы (отклонены):
- AI генерация (GPT) — непредсказуемое качество, стоимость API, latency
- Переиспользование квизов между сезонами — пользователи запомнят ответы
- Внешний Node.js скрипт (
child_process.exec) — хрупкий, сложно отлаживать, нет per-season конфигурации
Последствия: Быстрая генерация свежего контента, но ограничена данными в SQLite и набором шаблонов.
Почему review checkboxes в localStorage, а не в БД?
Проблема: При генерации контента в Season Wizard администратор проверяет квизы (Step 5), квесты (Step 6) и достижения (Step 7) вручную. Нужен механизм отслеживания прогресса проверки.
Решение: Хук useSeasonReview хранит состояние проверки в localStorage (admin-season-review-{seasonId}). Покрывает все 3 типа контента: quizzes, quests, achievements. Approval Step (Step 8) показывает сводку проверки как UI-предупреждение (не блокирует одобрение).
Предыдущее решение (отклонено): Поле isReviewed Boolean в модели Quiz + endpoint PATCH /admin/quizzes/:id/toggle-reviewed. Работало только для квизов, требовало API и миграций для каждого нового типа контента.
Почему localStorage:
- Один администратор — мультиустройственная синхронизация не нужна
- Review — это workflow-инструмент, не бизнес-данные (не влияет на публикацию)
- Потеря при очистке кэша допустима (просто перепроверить)
- Единый механизм для всех типов контента без backend-изменений
Последствия: Упрощённая архитектура — нет эндпоинтов, миграций, серверной логики. Масштабируется на новые типы контента без backend-изменений.
Почему кеширование статистики на 5 минут?
Статистика квизов (totalAttempts, avgScore, recentActivity) кешируется в памяти на 5 минут для снижения нагрузки на БД.
Проблема: Агрегация из QuizResult для каждого запроса — дорогая операция.
Решение: In-memory кеш с TTL 5 минут, автоочистка просроченных записей.
Альтернативы (отклонены):
- Redis кеш — overkill для админских запросов
- Материализованные вью — сложнее поддерживать
Последствия: Статистика может отставать до 5 минут, что допустимо для админки.
Почему копируем contentFingerprint при клонировании сезона?
При копировании контента сезона квизы клонируются с сохранением contentFingerprint для поддержания дедупликации.
Проблема: Через несколько сезонов игроки забывают вопросы. Нужен способ переиспользовать старый контент без создания дубликатов.
Решение: При клонировании сезона (SeasonCloneService.cloneFromSeason) поле contentFingerprint копируется из source квизов.
Альтернативы (отклонены):
-
Автоматический "сброс" fingerprints через N сезонов — отклонено потому что:
- Переиспользование контента должно быть явным решением администратора, а не автоматическим
- Риск случайных дубликатов при генерации нового контента
- Сложность в определении "правильного" значения N (6 сезонов? 12?)
- Нет гарантии что игроки действительно забыли вопросы
-
Сброс fingerprint в NULL при копировании — отклонено потому что:
- Нарушает дедупликацию: система не сможет определить что квиз уже существует
- При следующей генерации может создаться точная копия того же вопроса
- Нарушается инвариант "один контент = один fingerprint"
-
Ручное удаление старых квизов перед генерацией — отклонено потому что:
- Потеря аналитики и истории (totalAttempts, avgScore)
- Ломаются связи с QuizResult
- Необходимость cascade delete
Почему текущее решение лучше:
- ✅ Явный контроль: Админ выбирает КОГДА переиспользовать старый контент через клонирование
- ✅ Защита от дубликатов: Если после клонирования нужно добавить новые квизы — система не создаст копии существующих
- ✅ Гибкость: Клонирование сезона (с fingerprint) — для полного переиспользования контента
- ✅ Простота: "Clone = exact copy with full metadata" — предсказуемое поведение
- ✅ Консистентность: Дедупликация работает одинаково для сгенерированных и клонированных квизов
Последствия:
- Админ явно управляет жизненным циклом контента
- Cross-season дедупликация работает корректно для всех типов квизов
- Нет риска случайных дубликатов при миксе клонирования + генерации
- Аналитика и история сохраняются в исходных записях
Workflow для переиспользования контента через N сезонов:
1. Сезон 1 (2024-01) → 270 квизов сгенерированы → каждый получил contentFingerprint
2. Сезон 2-5 (2024-02 до 2024-06) → новые квизы, дедупликация работает
3. Сезон 6 (2024-07) → Админ клонирует Сезон 1 → квизы копируются с fingerprints
4. Система видит эти fingerprints как "existing content" → не создаёт дубликаты при генерации
5. Игроки получают знакомые вопросы, но уже не помнят ответы (прошло 6 месяцев)
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| QuizGeneratorService | backend/src/domains/quizzes/services/quiz-generator.service.ts | SINGLE quiz generation из SQLite + templates |
| QuizGeneratorMultipleService | backend/src/domains/quizzes/services/quiz-generator-multiple.service.ts | MULTIPLE quiz generation (grouping strategies) |
| SqliteService | backend/src/common/sqlite/sqlite.service.ts | Read-only доступ к rust_data.db |
| QuizService | backend/src/domains/quizzes/services/quiz.service.ts | Валидация и CRUD квизов |
| CategoryService | backend/src/domains/quizzes/services/category.service.ts | Управление категориями с каскадной активацией |
| SubcategoryService | backend/src/domains/quizzes/services/subcategory.service.ts | Управление подкатегориями |
| QuizStatsService | backend/src/domains/quizzes/services/quiz-stats.service.ts | Статистика с кешированием |
| Admin Routes | backend/src/domains/quizzes/routes/admin-quizzes.routes.ts | API эндпоинты |
| Schemas | backend/src/domains/quizzes/schemas/quiz.schemas.ts | Валидация запросов |
| Eligibility Config | quizzes/data/quiz-eligibility.json | Правила исключения предметов из квизов |
| DB Schema | quizzes/src/db/schema.sql | Схема SQLite (items.quiz_eligible) |
| Migration | quizzes/src/db/migrate.js | Pipeline: JSON → SQLite + eligibility enrichment |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| Category | Категория квизов | name, displayName, icon, color, isActive |
| Subcategory | Подкатегория | categoryId, name, isActive |
| Quiz | Вопрос квиза | question, questionType, options, correctAnswer, explanation, difficulty, rewardScrap, isActive |
| QuizResult | Результат прохождения | userId, quizId, score, percentage, correctAnswers, totalQuestions, timeSpent, earnedScrap |
Relationships
Key Fields
Quiz model details
| Поле | Тип | Описание |
|---|---|---|
questionType | SINGLE | MULTIPLE | Тип вопроса |
options | Json | [{text: string, isCorrect: boolean}] |
correctAnswer | Json | Строка (SINGLE) или массив строк (MULTIPLE) |
slug | String? | Идентификатор сущности: "mp5a4", "launch-site" |
entityType | String? | Тип: "item", "monument", "mechanic", "npc", "vehicle" |
difficulty | EASY | MEDIUM | HARD | Сложность |
timeLimit | Int? | Лимит времени в секундах |
rewardScrap | Int | Награда за правильный ответ |
currentSeasonId | String? | Привязка к сезону (null = не привязан) |
totalAttempts | Int | Счётчик попыток (денормализация) |
avgScore | Float | Средний балл (денормализация) |
6. API Endpoints
User API для квизов отсутствует — пользователи проходят квизы в комментариях к постам Telegram канала через бота.
- Quizzes
- Categories
- Subcategories
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/quizzes | Список квизов с фильтрами | → |
| GET | /admin/quizzes/:id | Детали квиза | → |
| POST | /admin/quizzes | Создание квиза | → |
| PUT | /admin/quizzes/:id | Обновление квиза | → |
| DELETE | /admin/quizzes/:id | Удаление квиза | → |
| PATCH | /admin/quizzes/:id/toggle-active | Переключение активности | → |
| POST | /admin/quizzes/bulk-action | Массовые операции | → |
| GET | /admin/quizzes/:id/stats | Статистика квиза | → |
| GET | /admin/quizzes/availability?seasonId&slug&categoryId? | Проверка наличия квизов по slug в сезоне (для Achievement Editor) | — |
7. Related
- Seasons — квизы генерируются для сезона (Step 5 Setup Wizard)
- Achievements — ачивки за прохождение квизов (по
slugиentityType) - Quests — квесты "пройди N квизов"
- Security Matrix — обзор защит Admin API