Skip to main content

Quizzes

1. Summary

Goal: Повысить вовлечённость чтения Telegram канала через автоматическую публикацию квизов сразу после выхода поста. Квиз живёт ~3 часа, затем удаляется — создаёт срочность и мотивирует включить уведомления.

User Value: Возможность заработать Scrap за правильные ответы в комментариях к посту. Ограниченное время создаёт FOMO и причину следить за каналом.

Планируемые награды

Сейчас: только Scrap. Планируется: XP, SP (не реализовано в коде).


2. Business Logic

Question Types

Описание: Один правильный ответ из нескольких вариантов.

Требования:

  • Ровно 1 правильный ответ (isCorrect: true)
  • Минимум 3 неправильных ответа
  • correctAnswer — строка, совпадающая с текстом правильного варианта

Пример: "Сколько HP у металлической двери?" → "250 HP"

Категоризация

Иерархия: CategorySubcategoryQuiz

📁 Category: "Rust"
├── 📂 Subcategory: "HP строений"
│ ├── Quiz: "Сколько HP у металлической двери?"
│ └── Quiz: "Сколько HP у каменной стены?"
└── 📂 Subcategory: "Крафт"
└── Quiz: "Сколько металла нужно для двери?"
Подкатегория обязательна

Квиз не может быть создан без привязки к подкатегории. Это обеспечивает структурированную организацию контента.

Core Mechanics

Validation Rules:

ПолеОграничения
question5-500 символов
explanation10-1000 символов
options[].text1-200 символов
optionsSINGLE: 1 верный + 3 неверных; MULTIPLE: 2+ верных + 1+ неверный
timeLimit10-600 секунд (опционально)
rewardScrap0-10000 (по умолчанию 100)
sourceвалидный HTTP/HTTPS URL
imageUrlHTTP/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 outfitsexact shortnamescientistsuit_heavy, attire.banditguard
Frankenstein NPC partsshortname LIKE%franken%
Admin-only weaponsexact shortnamerocket.launcher.rpg7
Items in developmentname LIKE%РАЗРАБОТКЕ%
Unobtainable attireexact shortnamejumpsuit.suit, hat.gas.mask
Promo/streaming itemsexact shortnamelumberjack.hoodie, twitchrivalsflag
Seasonal event itemsexact shortnamegloweyes, gingerbreadsuit

Enrichment выполняется при каждом migrate.js --force — поле вычисляется из конфига, не хранится в исходных JSON.

Добавление новых исключений

Для исключения предмета — добавить shortname в quizzes/data/quiz-eligibility.json и перегенерировать базу (node src/db/migrate.js --force). Код генератора менять не нужно.

Категории:

КатегорияItemsSINGLEMULTIPLESubcategoriesUtilization
weapons843879~85%
attire1313188~90%
food15724166~85%
construction206
items215
electrical186
tools186
components187
loot_containers189
fun177
ammo401664~70%
resources137
misc135
traps126
scientists105
medical5922~60%
animals97
vehicles97
missions53
vendors53
raid345312~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):

  1. Выбор категории → загрузка предметов с подсчётом доступных шаблонов на каждый предмет
  2. Опциональная фильтрация по подкатегории / поиск по имени
  3. Выбор предметов (чекбоксы) → отображение максимального количества шаблонов
  4. Генерация → всегда append-режим (без удаления существующих)

Input: { category, shortnames[], count? }

Алгоритм:

  1. Загрузить предметы из SQLite по shortnames
  2. Для каждого предмета проверить все шаблоны категории (SINGLE + MULTIPLE)
  3. Исключить дубли по question текста (с существующими квизами сезона)
  4. Записать в БД + инкрементировать 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"
Только targeted-генерация

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 LimitAuthValidation
CRUD квизовadminApiLimiter (50/min)Admin JWTCreateQuizSchema, UpdateQuizSchema
Get statsadminApiLimiter (50/min)Admin JWTGetQuizStatsSchema
Детали реализации

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

Edge Cases

СитуацияUI/Backend поведение
❌ Некорректный questionTypeQuizValidationError с указанием поля
❌ correctAnswer не совпадает с optionsQuizValidationError: "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 checkboxlocalStorage-чекбокс в 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 минут?

Performance

Статистика квизов (totalAttempts, avgScore, recentActivity) кешируется в памяти на 5 минут для снижения нагрузки на БД.

Проблема: Агрегация из QuizResult для каждого запроса — дорогая операция.

Решение: In-memory кеш с TTL 5 минут, автоочистка просроченных записей.

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

  • Redis кеш — overkill для админских запросов
  • Материализованные вью — сложнее поддерживать

Последствия: Статистика может отставать до 5 минут, что допустимо для админки.

Почему копируем contentFingerprint при клонировании сезона?

Content Reuse Strategy

При копировании контента сезона квизы клонируются с сохранением contentFingerprint для поддержания дедупликации.

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

Решение: При клонировании сезона (SeasonCloneService.cloneFromSeason) поле contentFingerprint копируется из source квизов.

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

  1. Автоматический "сброс" fingerprints через N сезонов — отклонено потому что:

    • Переиспользование контента должно быть явным решением администратора, а не автоматическим
    • Риск случайных дубликатов при генерации нового контента
    • Сложность в определении "правильного" значения N (6 сезонов? 12?)
    • Нет гарантии что игроки действительно забыли вопросы
  2. Сброс fingerprint в NULL при копировании — отклонено потому что:

    • Нарушает дедупликацию: система не сможет определить что квиз уже существует
    • При следующей генерации может создаться точная копия того же вопроса
    • Нарушается инвариант "один контент = один fingerprint"
  3. Ручное удаление старых квизов перед генерацией — отклонено потому что:

    • Потеря аналитики и истории (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

КомпонентПутьОписание
QuizGeneratorServicebackend/src/domains/quizzes/services/quiz-generator.service.tsSINGLE quiz generation из SQLite + templates
QuizGeneratorMultipleServicebackend/src/domains/quizzes/services/quiz-generator-multiple.service.tsMULTIPLE quiz generation (grouping strategies)
SqliteServicebackend/src/common/sqlite/sqlite.service.tsRead-only доступ к rust_data.db
QuizServicebackend/src/domains/quizzes/services/quiz.service.tsВалидация и CRUD квизов
CategoryServicebackend/src/domains/quizzes/services/category.service.tsУправление категориями с каскадной активацией
SubcategoryServicebackend/src/domains/quizzes/services/subcategory.service.tsУправление подкатегориями
QuizStatsServicebackend/src/domains/quizzes/services/quiz-stats.service.tsСтатистика с кешированием
Admin Routesbackend/src/domains/quizzes/routes/admin-quizzes.routes.tsAPI эндпоинты
Schemasbackend/src/domains/quizzes/schemas/quiz.schemas.tsВалидация запросов
Eligibility Configquizzes/data/quiz-eligibility.jsonПравила исключения предметов из квизов
DB Schemaquizzes/src/db/schema.sqlСхема SQLite (items.quiz_eligible)
Migrationquizzes/src/db/migrate.jsPipeline: 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
ПолеТипОписание
questionTypeSINGLE | MULTIPLEТип вопроса
optionsJson[{text: string, isCorrect: boolean}]
correctAnswerJsonСтрока (SINGLE) или массив строк (MULTIPLE)
slugString?Идентификатор сущности: "mp5a4", "launch-site"
entityTypeString?Тип: "item", "monument", "mechanic", "npc", "vehicle"
difficultyEASY | MEDIUM | HARDСложность
timeLimitInt?Лимит времени в секундах
rewardScrapIntНаграда за правильный ответ
currentSeasonIdString?Привязка к сезону (null = не привязан)
totalAttemptsIntСчётчик попыток (денормализация)
avgScoreFloatСредний балл (денормализация)

6. API Endpoints

Только Admin API

User API для квизов отсутствует — пользователи проходят квизы в комментариях к постам Telegram канала через бота.

МетодЭндпоинтОписание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)

  • Seasons — квизы генерируются для сезона (Step 5 Setup Wizard)
  • Achievements — ачивки за прохождение квизов (по slug и entityType)
  • Quests — квесты "пройди N квизов"
  • Security Matrix — обзор защит Admin API