Skip to main content

Rust Integration

1. Summary

Goal: Webhook API для интеграции с игровыми Rust серверами (V2 Protocol). Плагин на сервере получает критерии матчинга при подключении, делает matching локально и отправляет готовые прогресс-инкременты через QUEST_PROGRESS. Backend принимает 3 event type (PLAYER_CONNECTED, PLAYER_DISCONNECTED, QUEST_PROGRESS), применяет increments и управляет сессиями игроков. TIME data (minutesPlayed, pendingMinutes) передаётся внутри QUEST_PROGRESS.

User Value: Игровая активность в Rust засчитывается в квесты GOLOOT. Пользователь играет на сервере и автоматически получает награды за выполненные задания без ручного подтверждения.


2. Business Logic

Webhook Event Types

V2 Protocol: matching на стороне плагина

Описанные ниже event types (Action, Loot, Kill, Craft, Recycle, Explosive, Skill, Farming) определяют ЧТО отслеживает плагин и как настраивать квесты в админке. Они не являются отдельными webhook event types на backend — в V2 все они транспортируются через QUEST_PROGRESS.

При PLAYER_CONNECTED backend возвращает квесты с раскрытыми match criteria, плагин делает Contains matching и накапливает increments в ProgressBuffer. Один QUEST_PROGRESS webhook каждые 60 секунд содержит все накопленные {userQuestId, increment} пары.

PLAYER_CONNECTED — Игрок подключился к серверу

  • Создаёт сессию RustPlayerSession (lazy: только если есть активные квесты)
  • Возвращает список активных квестов для плагина

PLAYER_DISCONNECTED — Игрок отключился

  • Закрывает активную сессию
  • Финализирует прогресс TIME квестов

TIME data в QUEST_PROGRESS — Периодическое обновление времени передаётся как часть QUEST_PROGRESS (поля minutesPlayed, pendingMinutes)

  • Обновляет totalMinutes в сессии
  • Увеличивает прогресс TIME квестов
  • Поддерживает pendingMinutes для recovery после краша
  • Timestamp-based calculation: сравнивает userQuest.startedAt vs session.connectedAt для правильного расчёта времени
Фикс: квест взят после подключения

Если игрок взял квест "Наиграй 5 минут" после 200 минут игры, засчитывается только время с момента взятия квеста, а не всё время сессии. Это предотвращает автоматическое выполнение квестов при их получении.

Lazy Session Creation Fix: При создании сессии "лениво" (после взятия квеста), система ищет PLAYER_CONNECTED webhook в логах для определения реального времени подключения. Поле sessionCreatedAt хранит время создания записи в БД для аудита, а connectedAt — реальное время подключения игрока.

Action Context

Каждый update в QUEST_PROGRESS может содержать опциональный массив actions — контекст действий игрока, которые привели к increment. Используется для аудита и отладки в Admin Panel.

Short Keys (минимизация payload size):

KeyПолное названиеОписаниеПримеры значений
ttypeТип игрового событияGATHER, CRAFT, KILL_ANIMAL, KILL_SCIENTIST, LOOT_CONTAINER, LOOT_ITEM, RECYCLE, EXPLOSIVE, FISH, TEA, HARVEST, PIE, MISSION, COMMAND, SKILL_UPGRADED, SKILL_MAXED
ddetailЧто именно (primary subject)"wood", "rifle.ak", "bear", "OIL_RIG", "crate_elite", "metal.ore"
xextraКонтекст/инструмент (опциональный)"stone.pickaxe" (tool), "rifle.ak" (weapon), "SAFE_ZONE" (recycler type), null
ncountАгрегированное количествоЧисло (суммировано за buffer interval)

Агрегация в плагине:

Actions агрегируются в ProgressBuffer на стороне плагина. Если игрок 3 раза собрал wood киркой за один buffer interval (60с), плагин отправит одну запись { "t": "GATHER", "d": "wood", "x": "stone.pickaxe", "n": 3 } вместо трёх отдельных.

Примеры actions для разных event types

GATHER (добыча ресурсов):

{ "t": "GATHER", "d": "wood", "x": "stone.pickaxe", "n": 150 }
{ "t": "GATHER", "d": "metal.ore", "x": "jackhammer", "n": 300 }

KILL_ANIMAL (убийство животного):

{ "t": "KILL_ANIMAL", "d": "bear", "x": "rifle.ak", "n": 1 }
{ "t": "KILL_ANIMAL", "d": "wolf", "x": "bow.hunting", "n": 2 }

KILL_SCIENTIST (убийство NPC):

{ "t": "KILL_SCIENTIST", "d": "OIL_RIG", "x": "rifle.lr300", "n": 3 }
{ "t": "KILL_SCIENTIST", "d": "TUNNEL_DWELLER", "x": "smg.mp5", "n": 1 }

LOOT_CONTAINER (лут контейнера):

{ "t": "LOOT_CONTAINER", "d": "crate_elite", "n": 1 }
{ "t": "LOOT_CONTAINER", "d": "loot_barrel_1", "n": 5 }

CRAFT (крафт предмета):

{ "t": "CRAFT", "d": "rifle.ak", "n": 1 }
{ "t": "CRAFT", "d": "ammo.rifle", "n": 3 }

RECYCLE (переработка):

{ "t": "RECYCLE", "d": "rope", "x": "SAFE_ZONE", "n": 10 }
{ "t": "RECYCLE", "d": "sewingkit", "x": "RADTOWN", "n": 5 }

EXPLOSIVE (использование взрывчатки):

{ "t": "EXPLOSIVE", "d": "explosive.timed", "n": 2 }
{ "t": "EXPLOSIVE", "d": "ammo.rocket.basic", "n": 4 }

SKILL_UPGRADED / SKILL_MAXED (SkillTree):

{ "t": "SKILL_UPGRADED", "d": "Woodcutting", "x": "Lumberjack", "n": 1 }
{ "t": "SKILL_MAXED", "d": "Mining", "x": "Miner", "n": 1 }
Backend не обрабатывает actions

Backend handler не изменён для работы с actions — массив автоматически сохраняется в payload JSON через webhook log. Это позволяет просматривать действия в Admin Panel без дополнительной обработки на бэкенде.

Admin Panel отображение

В Admin Panel (Rust Server Detail → Webhook Logs) для QUEST_PROGRESS событий actions отображаются в виде сгруппированных чипов: действия группируются по t (типу), показывают d (detail) как элементы списка и общий n (count). Если все actions одного типа имеют одинаковый x (extra) — он выносится в заголовок группы.

Event → Quests & Achievements

V2 Protocol: backend принимает только 3 event type. Все игровые события (ресурсы, убийства, крафт, etc.) транспортируются через QUEST_PROGRESS — плагин уже сделал matching и шлёт только {userQuestId, increment}. TIME data также передаётся внутри QUEST_PROGRESS.

Event TypeКвестыДостиженияПримечание
PLAYER_CONNECTED✅ sessionСоздаёт сессию + возвращает список квестов с match criteria
PLAYER_DISCONNECTED✅ sessionЗакрывает сессию, финализирует TIME квесты
QUEST_PROGRESSПрименение pre-matched increments + TIME data (minutesPlayed, pendingMinutes)
Достижения в V2

RUST достижения были удалены в рамках V2 миграции (commit 0f4460752). Достижения типа "сыграй X часов в Rust" и "убей Y NPC" более не поддерживаются. Прогресс достижений через Rust события не обновляется.

История событий

V1 (до V2 миграции) поддерживал 16 event types на backend: TIME_UPDATE, COMMAND_EXECUTED, RESOURCE_GATHERED, CONTAINER_LOOTED, ITEM_LOOTED, ANIMAL_KILLED, SCIENTIST_KILLED, ITEM_CRAFTED, FISH_CAUGHT, ITEM_RECYCLED, EXPLOSIVE_USED, TEA_BREWED, FARMING_HARVEST, PIE_COOKED, MISSION_COMPLETED, SKILL_UPGRADED, SKILL_MAXED, SKILL_TREE_COMPLETED + BATCH_UPDATE. В V2 все игровые события консолидированы в QUEST_PROGRESS — matching перенесён на сторону плагина.

Core Mechanics

1. Steam → User Linking

Webhook содержит steamId. Система находит пользователя через SteamLinkingService.findUserBySteamId() (с Redis кэшем, TTL 5 мин, инвалидация при linkSteamToUser()).

Если пользователь не найден

Webhook возвращает { success: true } немедленно без создания записи в БД и без поля data. Лог не создаётся — ~80% webhook-ов приходят от не-goLoot игроков, и запись каждого переполнила бы БД.

Плагин-сайд: InitializePlayerSession проверяет response.Data == null — если data отсутствует, сессия в _activeSessions не создаётся. Однако при вызове /goloot без активной сессии плагин автоматически вызывает InitializePlayerSession (lazy init) — если к этому моменту Steam привязан, сессия создаётся без переподключения.

Удаление Steam привязки во время игры

При вызове clearSteamVerification (DELETE /api/users/steam/trade-url) backend выполняет полную "церемонию отвязки":

  1. Очищает steamId, steamTradeUrl, флаги верификации
  2. Закрывает активные RustPlayerSession для этого steamId (в той же транзакции)
  3. Инвалидирует кэш steamUser:{steamId} (предотвращает stale lookups в webhook pipeline)

Прогресс квестов (UserQuest.currentProgress) не сбрасывается — он привязан к userId, не к steamId. При повторной привязке того же Steam аккаунта прогресс продолжится. При привязке другого Steam — linkSteamToUser() сбросит прогресс Rust квестов.

2. Lazy Session Creation

Сессия RustPlayerSession создаётся только при наличии активных квестов:

Игрок подключился → Есть активные Rust квесты?
├─ Да → Создать сессию, вернуть список квестов
└─ Нет → Не создавать сессию, вернуть пустой список

Если игрок берёт квест после подключения — сессия создаётся при следующем QUEST_PROGRESS с TIME data.

3. Atomic Progress Updates

Все методы обновления прогресса используют атомарный increment:

UPDATE user_quests SET "rustLootCount" = "rustLootCount" + 1
Race Condition Protection

Плагин может отправлять параллельные webhook-и (несколько событий одновременно). Атомарный increment предотвращает потерю данных при concurrent writes.

4. Season Status Check

Прогресс квестов обновляется только при Season.status = ACTIVE:

Webhook → isSeasonActive()?
├─ Да → Обновить прогресс
└─ Нет (COUNTDOWN/COMPLETED) → Вернуть пустой список квестов
Shared Cache Key

isSeasonActive() использует SeasonRepository.getActiveSeason() (полный объект). Нельзя использовать собственный запрос с select: { id: true } — это отравит общий Redis-кэш activeSeason и может вызвать преждевременное завершение сезона. См. ADR в seasons.md.

5. Frontend Online Status Polling

TMA проверяет статус "на сервере" каждые 30 секунд через GET /api/users/rust-online-status. SSE отложен на будущее.

6. Performance Optimization (при 500 GoLoot игроках)

МетрикаV1 (до миграции)V2 (текущее)Снижение
req/s~170~12-93%
DB queries/s~680~25-96%
DB queries/webhook (QUEST_PROGRESS)2N sequentialN+3 parallel-значительно

V2-специфичные оптимизации:

  • Plugin-side matching — backend не делает matching по условиям квестов, только применяет increments
  • expand-match-criteria.ts — раскрытие категорий в конкретные shortnames выполняется один раз при PLAYER_CONNECTED, а не на каждый webhook
  • Pre-snapshot + parallel updateshandleQuestProgress снимает snapshot активных квестов, строит metaMap, затем запускает все applyProgressIncrement через Promise.allSettled (параллельно, без JOIN)
  • Query budget V2: 1(snapshot) + N(parallel, no JOIN) + 1(session) + 1(final) = N+3

Прочие оптимизации: batch sync (POST /tasks/batch), Redis кэш findUserBySteamId, connection pool 25, rate limit 400/min per-server.

7. Monitoring & Alerting

Prometheus метрики для batch processing (экспортируются на /metrics):

МетрикаТипОписание
webhook_batch_duration_secondsHistogramВремя обработки batch (buckets: 1s–60s)
webhook_batch_playersHistogramКоличество игроков в batch
webhook_batch_errors_totalCounterПолностью провалившиеся batch-и

Grafana alert rules (provisioning в monitoring/grafana/provisioning/alerting/):

АлертПорогSustainedSeverityЧто значит
Batch Duration Warningp95 > 20s5 minwarningПриближаемся к лимиту (batch guard 30s)
Batch Duration Criticalp95 > 30s3 mincriticalBatch guard сбрасывает _isBatchSending, возможна потеря данных
Batch Errorsлюбой полный failinstantcriticalВсе игроки в batch failed — проверить DB connectivity

Алерты идут в Telegram группу (TELEGRAM_BOT_TOKEN — env var в Dokploy, chatid/message_thread_id — хардкод в monitoring/grafana/provisioning/alerting/contact-points.yml, т.к. Grafana provisioning coerces numeric env vars to int).

Dashboard: Webhook Batch Monitoring (uid: webhook-batch-monitoring) — duration trend (p50/p95/max), player count, error rate, heatmap.

PromQL примеры для диагностики
# Текущая p95 длительность batch
histogram_quantile(0.95, rate(webhook_batch_duration_seconds_bucket[5m]))

# Среднее количество игроков в batch
histogram_quantile(0.5, rate(webhook_batch_players_bucket[5m]))

# Количество ошибок за последний час
increase(webhook_batch_errors_total[1h])

# Количество batch-ей в минуту
rate(webhook_batch_duration_seconds_count[5m]) * 60

Category Matching

В V2 matching выполняется на стороне плагина. При PLAYER_CONNECTED backend возвращает квесты с раскрытыми match criteria (через expand-match-criteria.ts). Плагин делает HashSet.Contains проверку при каждом игровом событии и при совпадении добавляет increment в ProgressBuffer.

Как работает expand-match-criteria.ts

Функция expandMatchCriteria(eventType, rustParams) раскрывает категории в конкретные значения при PLAYER_CONNECTED:

  • ORE["metal.ore", "sulfur.ore", "hq.metal.ore"]
  • ANY_QUARRY["QUARRY", "EXCAVATOR"]
  • PREDATOR["BEAR", "POLAR_BEAR", "WOLF", "BOAR", "TIGER", "PANTHER", "SNAKE", "CROCODILE", "SHARK"]
  • ["*"] = wildcard (матчит любое значение)
  • undefined = не проверять (пропустить критерий)

Результат записывается в поле match: QuestMatchCriteria ответа PLAYER_CONNECTED.

Квесты поддерживают гибкие условия через категории (настраиваются в админке, раскрываются backend при CONNECTED):

Quest TypeПоддерживаемые значения
GATHERANY, ORE, конкретный shortname. Способ добычи: DISPENSER, COLLECTIBLE, QUARRY, EXCAVATOR, ANY_QUARRY
LOOT_CONTAINERANY, ANY_BARREL, ANY_CRATE, ANY_BASIC, ANY_UNDERWATER, ANY_UWL, ANY_SPECIAL, ANY_CODELOCKED, конкретный тип
KILL_ANIMALКатегории: ANY, PREDATOR (bear, polar_bear, wolf, boar, tiger, panther, snake, crocodile, shark), JUNGLE (tiger, panther, snake, crocodile). Конкретные: BEAR, POLAR_BEAR, WOLF, BOAR, STAG, CHICKEN, SHARK, TIGER, PANTHER, CROCODILE, SNAKE
KILL_SCIENTISTГранулярные: OIL_RIG, OIL_RIG_HEAVY, BRADLEY_GUARD, BRADLEY_HEAVY, CARGO_SHIP, TUNNEL_DWELLER, MILITARY_TUNNEL, UNDERWATER, EXCAVATOR, JUNKPILE, PATROL, SILO, ABANDONED_MILITARY_BASE. Deep Sea: DEEP_SEA_ISLAND, DEEP_SEA_PTBOAT, DEEP_SEA_RHIB. Техника: BRADLEY_APC, PATROL_HELICOPTER. Мета: ANY, HEAVY, BRADLEY, OIL_RIG_ALL, DEEP_SEA, VEHICLES
CRAFTТолько конкретные shortnames (без категорий): rifle.ak, ammo.rifle, explosive.timed
RECYCLE (shortname)null (ANY) или конкретный shortname: metalpipe, roadsigns, gears
RECYCLE (recyclerType)ANY, SAFE_ZONE, RADTOWN
EXPLOSIVE_USEDМета-категории: ANY, ROCKET, GRENADE, C4, SATCHEL, SURVEY. Гранулярные: BASIC_ROCKET, HV_ROCKET, FIRE_ROCKET, MLRS_ROCKET, F1_GRENADE, BEANCAN_GRENADE, HE_GRENADE_LAUNCHER, EXPLOSIVE_AMMO
WeaponANY, MELEE, RANGED, EXPLOSIVE, конкретный shortname

Protection

ДействиеRate LimitAuthValidation
WebhookrustWebhook (400/min per-server)Plugin API KeyRustWebhookPayloadSchema
Batch tasksrustWebhook (400/min per-server)Plugin API KeyBatchActiveQuestsRequestSchema
Get tasksrustWebhook (400/min per-server)Plugin API KeySteamId param
Health checkrustWebhook (400/min per-server)Plugin API Key
List serversgeneralAdmin JWT
Create servermutationsAdmin JWTCreateRustServerSchema
Update servermutationsAdmin JWTUpdateRustServerSchema
Delete servermutationsAdmin JWTDeleteRustServerSchema
Reveal credentialsmutationsAdmin JWT + Password + 2FARevealCredentialsSchema
Get logsgeneralAdmin JWTGetServerLogsSchema
Plugin Auth

Плагин аутентифицируется через Bearer token в заголовке Authorization. API Key уникален для каждого сервера и генерируется при создании.

Rate limit: rustWebhook — 400 req/min per-server (ключ: rustServer.id, увеличен с 200 для серверов с 200+ игроками). Нормальная нагрузка после оптимизации ~22 req/min, лимит с запасом ~18×. При превышении плагин получает 429 с retryAfter. Fallback на IP если сервер ещё не идентифицирован.

Edge Cases

Что происходит при ошибках:

СитуацияПоведение системы
Steam ID не найденWebhook успешно обработан, прогресс игнорируется
Сервер деактивирован403 Forbidden, Server is deactivated
Невалидный API Key401 Unauthorized, Invalid API key
Нет активного сезонаКвесты не обновляются, сессия не создаётся
Дубликат событияDB unique constraint (P2002) на deduplicationKey блокирует повторную обработку
Краш плагинаpendingMinutes в следующем QUEST_PROGRESS для recovery
Квест взят после подключенияСчитается только время с момента взятия (timestamp-based calculation)
Переподключение с активным квестомНакапливается старый прогресс + новое время сессии
Сервер с x10 рейтами (GATHER)Плагин накапливает больший increment → квест выполняется быстрее (by design)
Tea buff при добычеБонус включается в amount → плагин накапливает больший increment (by design)
Instant mining (SkillTree)Засчитывается через кастомный хук OnInstantGatherTriggered — плагин получает item.amount и добавляет в ProgressBuffer
Backend недоступен (HTTP 0)Exponential backoff (5s, 10s, 20s ±25% jitter, cap 60s) → DLQ → retry каждые 30 сек, TTL 30 мин. Circuit breaker: после 5 consecutive failures новые webhooks идут прямо в DLQ, DLQ probe восстанавливает
Дублирование InitializePlayerSession_pendingSessionInit HashSet — если init in-flight для игрока, повторный вызов (из Loaded, OnPlayerConnected, /goloot) скипается
Plugin unload с непустой DLQWarning в логах: Plugin unloading with N unsent webhooks in DLQ! — данные теряются
Rate limit 429 (Rust endpoint)Плагин получает 429 с retryAfter, может retry. Лимит 400 req/min per-server
Stale userQuestId в QUEST_PROGRESSBackend проверяет pre-snapshot: если userQuestId не в active snapshot — update пропускается (квест завершён или отозван)
useAbsolute: true (Ultimate квесты)Плагин шлёт absoluteValue вместо increment. Backend применяет SET вместо INCREMENT (защита от respec-атаки)
Completed quest stale datahandleQuestProgress берёт completed quests из pre-snapshot (до инкремента в БД). mapCompletedQuests enforces current = Math.max(current, target)
ProgressBuffer flush vs InFlightProgressBuffer использует свой InFlight ключ. Если QUEST_PROGRESS в полёте — новые события копятся в буфере до следующего тика
TIME data в QUEST_PROGRESSminutesPlayed в QUEST_PROGRESS обрабатывается внутри handleQuestProgress — обновляет сессию и TIME квесты
Disconnect во время batchБуферы перемещаются в _disconnectedBuffers → включаются в следующий batch (expiry 5 min)
Reconnect до отправки batch_disconnectedBuffers мержится обратно в новую сессию при InitializePlayerSession()
Batch в полёте (_isBatchSending)Глобальный флаг _isBatchSending блокирует отправку следующего batch до ответа (timeout 30s)
Crash recovery (PROCESSING)При retry с тем же webhookId: batch log в PROCESSING → re-process всех игроков. applyProgressIncrement идемпотентен (WHERE status=IN_PROGRESS)
Backend chunking50 players per chunk. Promise.allSettled — partial failure не блокирует остальных
Plugin unload с disconnected buffersSaveToPending() сохраняет pending minutes для каждого disconnected buffer
Квест взят пока игрок онлайнПри следующем batch response плагин получает обновлённый activeQuests с новым квестом и его match criteria
Backend Error Codes (для API/тестов)
КодHTTPКонтекст
Missing or invalid Authorization header401Нет Bearer token
Invalid API key401Неверный API key
Server is deactivated403Сервер выключен
Server not found404Сервер не существует
Cannot delete server with related data409Есть связанные логи/сессии/квесты

Troubleshooting

"Плагин показывает квесты, но backend возвращает пустой список"

Возможные причины:

  1. Сезон неактивен - проверить SELECT * FROM seasons WHERE "isActive" = true

    • Backend возвращает [] если нет активного сезона
    • Плагин кэширует старый список квестов локально
  2. Quest не привязан к серверу - проверить Quest.rustServerId

    SELECT id, title, "rustServerId" FROM quests WHERE title = 'имя квеста';
  3. UserQuest статус не IN_PROGRESS - проверить статус

    SELECT status FROM user_quests WHERE "questId" = 'xxx' AND "userId" = 'yyy';
  4. Логи плагина из разных файлов - плагин создаёт новый файл лога в 00:00

    • Старые логи могут показывать состояние до активации сезона/взятия квеста
Диагностика через логи

Ищите в логах плагина:

  • Webhook QUEST_PROGRESS success: {"activeQuests": [...]} - что вернул backend
  • Syncing quests for 1 players without quests - backend вернул []
  • Quest 'X' completed - квест выполнен корректно

Alert Runbook — что делать когда пришёл алерт в Telegram

Главное правило

Пришёл алерт → открой Claude Code → /diagnostics "текст алерта". Claude сам залезет в Grafana (Prometheus + Loki), найдёт причину и скажет что делать. Тебе не нужно разбираться в PromQL/LogQL.

Warning = посмотри когда будет время, система справляется. Critical = посмотри сейчас, но не паникуй — данные не теряются в первые 10 минут (DLQ плагина).

#АлертSeverityЧто значитЧто делать
1Batch Duration Warning (p95 > 20s, 5 min)warningBatch приближается к лимиту. Окно 60s, guard 30s — ты в жёлтой зоне/diagnostics "batch processing slow" — вероятно больше игроков чем обычно или DB медленная. Ничего не чини срочно
2Batch Duration Critical (p95 > 30s, 3 min)criticalBatch guard сбрасывает _isBatchSending. Возможны дубликаты или пропуски прогресса/diagnostics "webhook batch critical duration" — если причина рост игроков → нужна оптимизация (но не на горящей системе). Если DB тормозит без роста → проверить другие эндпоинты
3Batch Errors (полный fail)criticalВСЕ игроки в batch failed (100%) — почти наверняка DB connectivity/diagnostics --errors + проверить curl https://api/health. Данные в DLQ плагина, ретрай каждые 30s, TTL 10 мин
4High Error Rate (>5% 5xx, 5 min)criticalБольше 5% HTTP запросов возвращают 500. Все эндпоинты, не только webhook/diagnostics --errors — Claude покажет какие эндпоинты падают. Если всё — серверная проблема (DB/memory/crash)
5High Response Time (p95 > 1s, 5 min)warningМедленные ответы по всему API. Пользователи TMA ждут/diagnostics "slow responses" — часто тяжёлый запрос блокирует connection pool
Запас прочности и пороги
МетрикаНорма (500 игроков)WarningCriticalФизический лимит
Batch duration~7s>20s>30s60s (интервал таймера)
Batch guard timeout30sGuard сбрасывает флаг
DLQ TTL30 мин (потеря данных)
Circuit breakerclosedopen (5+ consecutive failures)DLQ probe every 30s
Rate limit headroom~18×400 req/min per-server

При нормальной работе batch занимает ~7s из 60s доступных (88% idle). Warning на 20s = ещё 67% запаса до физического лимита.


3. ADR (Architectural Decisions)

Почему Lazy Session Creation?

Проблема: Сессии занимают место в БД и требуют обработки при каждом QUEST_PROGRESS. Большинство игроков не имеют активных Rust квестов.

Решение: Создавать сессию только при наличии активных квестов. getOrCreateSession() создаёт сессию при первом QUEST_PROGRESS с TIME data после взятия квеста.

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

  • Создавать сессию всегда при подключении — лишняя нагрузка, много неиспользуемых записей
  • Не хранить сессии, вычислять время из логов — сложный запрос, медленно

Последствия: Меньше записей в БД, но небольшая задержка при первом квесте (создание сессии).

sessionCreatedAt для аудита

Поле sessionCreatedAt добавлено для различения:

  • connectedAtреальное время подключения игрока (из PLAYER_CONNECTED webhook)
  • sessionCreatedAt — когда создана запись в БД (для аудита lazy creation)

При lazy creation система ищет последний PLAYER_CONNECTED webhook с подходящим steamId и использует его createdAt для connectedAt. Если webhook не найден — используется fallback на текущее время.

Почему Atomic Increment?

Проблема: Плагин может отправить несколько webhook-ов одновременно (игрок быстро собирает ресурсы). При обычном read-modify-write возможна потеря данных:

Thread 1: Read count = 5
Thread 2: Read count = 5
Thread 1: Write count = 6
Thread 2: Write count = 6 // Потеряли +1!

Решение: PostgreSQL атомарный increment через Prisma: { increment: amount }. SQL гарантирует атомарность.

Последствия: Корректный подсчёт при любой concurrency, но два запроса к БД (increment + check completion).

Почему Category Matching?

Проблема: Квесты должны поддерживать гибкие условия: "убей любого хищника" или "собери руду".

Решение: Иерархия категорий в rust.types.ts:

  • RUST_ANIMAL_CATEGORIES: PREDATOR → [BEAR, POLAR_BEAR, WOLF, BOAR, TIGER, PANTHER, SNAKE, CROCODILE, SHARK]
  • RUST_RESOURCE_CATEGORIES: ORE → [metal.ore, sulfur.ore, hq.metal.ore]
  • RUST_LOOT_CONTAINER_PREFABS: ANY_CRATE → [crate_normal, crate_elite, ...]

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

  • Списки в каждом квесте — дублирование, сложно менять категории
  • Regex в условиях — медленно, сложно отлаживать

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

Почему Re-verification при Reveal Credentials?

Проблема: API Key сервера — критический секрет. Компрометация позволяет отправлять фейковые webhook-и.

Решение: Эндпоинт /reveal-credentials требует повторной верификации:

  1. Проверка пароля админа
  2. Проверка 2FA кода (если включена)
  3. Audit logging успешных запросов

Последствия: Защита от session hijacking, но неудобство при частом доступе к credentials.

Почему один OnEntityDeath хук в плагине?

Проблема: Oxide/uMod при наличии нескольких перегрузок OnEntityDeath (например: BaseNpc, SimpleShark, BaseCombatEntity) вызывает только самую общую — BaseCombatEntity. Специфичные хуки игнорируются.

Решение: Один унифицированный хук OnEntityDeath(BaseCombatEntity entity, HitInfo info) с определением типа entity через словари AnimalTypes и ScientistTypes.

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

  • Отдельные хуки для каждого типа (OnEntityDeath(BaseNpc), OnEntityDeath(SimpleShark)) — Oxide не вызывает их при наличии BaseCombatEntity
  • Проверка типа через is BaseNpc — не покрывает SimpleShark (акулы) и BaseNPC2 (джунглевые животные Gen2)

Последствия: Унифицированная обработка всех entity типов через ShortPrefabName lookup, но требует полных словарей для классификации.

Reference

Подход взят из UltimateLeaderboard — один из наиболее надёжных плагинов для учёта статистики.

Почему timestamp-based расчёт времени для TIME квестов?

Проблема: Квест "Наиграй 5 минут" засчитывался сразу, если игрок взял его после 200 минут игры в текущей сессии. updateTimeProgress() использовал абсолютное время сессии без учёта когда квест был взят.

Решение: Сравнение двух timestamp-ов:

  • userQuest.startedAt — когда взят квест
  • session.connectedAt — когда началась сессия

Логика:

УсловиеРасчёт времениПример
Квест ПОСЛЕ подключенияВремя с момента взятия квестаСессия 200 мин → взял квест → 5 мин прогресса
Квест ДО подключенияСтарый прогресс + новое время сессииВзял квест → отключился (50 мин) → переподключился → +30 мин = 80 мин
Нет активной сессииСохранённый прогрессOffline tracking

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

  • Добавить поле baseSessionMinutes в UserQuest — создаёт зависимость от session lifecycle, ломается при переподключениях
  • Сбрасывать прогресс при взятии квеста — несправедливо для игроков, которые переподключаются
  • Игнорировать проблему — недопустимо, т.к. позволяет мгновенное выполнение квестов

Последствия: Справедливый подсчёт времени для всех сценариев. Backward compatible: существующие квесты без сессии используют сохранённый прогресс.

Lazy Session Init в /goloot

Проблема: Если игрок подключился до привязки Steam в TMA, сессия не создаётся (InitializePlayerSession → backend не находит user → Data == null → skip). При вызове /goloot — "Сессия не найдена. Переподключитесь".

Решение: /goloot при отсутствии сессии вызывает InitializePlayerSession с callback:

  • Если backend теперь находит user (Steam привязан) → сессия создаётся, welcome message
  • Если user не найден → сообщение "Привяжите Steam в GoLoot приложении и попробуйте снова"

Последствия: Игроку не нужно переподключаться к серверу после привязки Steam. Rate-limited через _golootCooldowns.

Почему /goloot НЕ отправляет данные синхронно?

Проблема: Команда /goloot показывает прогресс квестов. Возникла идея: при вызове команды сразу отправлять накопленный буфер на backend, чтобы игрок видел "честный" прогресс на сервере.

Решение: Отклонено. Оставить текущую архитектуру с батчами.

Почему не нужно:

  1. Проблема не существует/goloot уже показывает реальный прогресс:

    // Прогресс = backend + локальный буфер
    totalMinutes = quest.CurrentMinutes + accumulatedMinutes;
  2. Данные не теряются — защита на всех уровнях:

    • OnPlayerDisconnected — flush при выходе
    • PendingSaveInterval (120 сек) — сохранение на диск
    • Unload() — flush при выгрузке плагина
  3. KISS — добавление синхронной отправки требует:

    • Lock на буфер (race condition с batch timer)
    • Очистка буфера после flush
    • Обработка ошибок отправки
    • Дублирование логики SendXxxUpdates()

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

  • Flush буфера при /goloot — переусложнение ради психологического комфорта
  • Уменьшить batch interval до 15 сек — увеличит нагрузку на API в 4 раза

Последствия: Простая архитектура. Максимальная задержка синхронизации — 60 секунд (batch interval), что приемлемо для UX.

Почему техника использует KILL_SCIENTIST?

Проблема: Нужны квесты на уничтожение Bradley и Patrol Helicopter. Создавать новый тип квеста или расширить существующий?

Решение: Расширить KILL_SCIENTIST новыми категориями: BRADLEY_APC, PATROL_HELICOPTER, VEHICLES (мета).

Почему так:

  1. Переиспользование инфраструктуры — те же поля в rustParams (killScientistType, killScientistCount, killWeaponType)
  2. Упрощение плагина — один webhook event SCIENTIST_KILLED, тот же буфер KillScientistBuffer
  3. UI готов — админка уже умеет показывать scientist types, добавили только новую группу "Техника"

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

  • Отдельный тип квеста KILL_VEHICLE — дублирование полей, новый webhook event, больше кода
  • События BRADLEY_DESTROYED, HELICOPTER_DESTROYED — разные обработчики, сложнее поддерживать

Последствия: Техника — это "специальные NPC" с точки зрения системы квестов. Фильтр EXPLOSIVE добавлен для реалистичности (танк гранатой не убьёшь).

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

Проблема: prefab.Contains("bear") срабатывает и на bear, и на polarbear → некорректная классификация животного.

Решение: AnimalTypes.ContainsKey(shortName) — точное совпадение ключа в словаре с StringComparer.OrdinalIgnoreCase.

ShortPrefabName: "bear"      → AnimalTypes["bear"]      ✓ BEAR
ShortPrefabName: "polarbear" → AnimalTypes["polarbear"] ✓ POLAR_BEAR (не BEAR!)

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

  • Contains() паттерн — ложные срабатывания на подстроках
  • Regex matching — медленнее, сложнее поддерживать

Последствия: Надёжная детекция без ложных срабатываний, но требует полного списка prefab shortnames в словаре.

Почему plugin-side matching (V2)?

Проблема (V1): Backend получал сырые игровые события (RESOURCE_GATHERED, ITEM_CRAFTED, etc.) и для каждого события выполнял matching по условиям всех активных квестов игрока. При N квестах и M событиях в секунду — N×M проверок. При 500 игроках и 20 активных квестов каждый — нагрузка была значительной.

Решение (V2): Matching перенесён на сторону плагина.

  1. При PLAYER_CONNECTED backend раскрывает условия квестов в конкретные значения (expandMatchCriteria) и возвращает match: QuestMatchCriteria
  2. Плагин строит HashSet<string> из полученных значений — O(1) Contains check
  3. При игровом событии плагин делает Contains → если совпадение — добавляет {userQuestId, increment} в ProgressBuffer
  4. Периодически (60с) шлёт QUEST_PROGRESS с готовыми increments. Backend просто применяет — без matching

Ключевые решения:

  1. expandMatchCriteria выполняется один раз при CONNECTED, не на каждый webhook
  2. ["*"] = wildcard (любое значение), undefined = критерий не проверять
  3. absoluteValue для Ultimate квестов — SET вместо INCREMENT (защита от respec)
  4. Один лог на QUEST_PROGRESS (не N логов) — экономия DB writes
  5. Один webhookId на batch — защита от повторной отправки через P2002

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

  • Оставить matching на backend — рост нагрузки с количеством квестов
  • WebSocket push от backend к плагину — Rust плагины не поддерживают нативно

Последствия: Backend handler'ов: 16 → 1. DB queries на webhook: 2N sequential → N+3 parallel. Плагин: 5877 → 2569 строк (-56%). Синхронизация категорий между backend и плагином не нужна — backend передаёт раскрытые значения.

Почему server-side batching (QUEST_PROGRESS_BATCH)?

Проблема: При 200+ одновременных игроках плагин отправлял 200 индивидуальных POST /rust/webhook в минуту (по одному на игрока) + отдельный POST /rust/tasks/batch для синхронизации квестов. Это упиралось в rate limit 400/min при ~400 игроках и создавало избыточный HTTP overhead.

Решение: POST /rust/webhook/batch — один запрос на цикл (60s) содержит данные ВСЕХ игроков. Ответ содержит activeQuests для каждого игрока, заменяя отдельный механизм синхронизации.

BEFORE:  Plugin → 200× POST /rust/webhook (per-player) + 1× POST /rust/tasks/batch (sync)
AFTER: Plugin → 1× POST /rust/webhook/batch (all players + sync in response)

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

  • Сохранить per-player webhooks + увеличить rate limit — не решает HTTP overhead
  • Batch только sync, оставить per-player progress — два механизма вместо одного

Ключевые решения:

РешениеВыборПочему
Logging1 batch summary + N per-player recordsAdmin DX preserved
DedupPROCESSING→PROCESSED status + P2002Crash recovery без потери данных
Chunking50 players per chunkСовпадает с DB connection pool
SyncУбран отдельный endpoint — batch response несёт activeQuestsОдин механизм вместо двух
Immediate sendsУбраны — весь прогресс через batchПроще архитектура
DisconnectБуфер disconnected players → следующий batchДанные не теряются

Последствия: HTTP requests 201/min → 1/min. Rate limit headroom 2× → 400×. POST /rust/tasks/batch и SyncAllPlayerQuestState() удалены.

Почему QUEST_PROGRESS generic handler?

Проблема (V1): 16 type-specific обработчиков на backend (handleResourceGathered, handleItemCrafted, handleAnimalKilled, ...). Добавление нового типа квеста требовало: новый event type, новый handler, новая Zod схема, новое поле в RustEventData, новый тип в RustWebhookEventType, обновление плагина.

Решение (V2): Один generic handler handleQuestProgress. Backend получает {userQuestId, increment} — он не знает и не должен знать, что именно игрок сделал (добыл ресурс, убил NPC, скрафтил предмет). Это ответственность плагина.

Как это стало возможным:

  • Плагин получает userQuestId при CONNECTED через match criteria
  • Плагин знает какой квест на что реагирует (из type поля)
  • Backend хранит только targetProgress и currentProgress — универсальные поля

Ключевые решения:

  1. applyProgressIncrement принимает questMeta (передаётся из pre-snapshot) — без JOIN к таблице квестов
  2. updates — массив, все применяются через Promise.allSettled (параллельно)
  3. completedQuests строится из pre-snapshot, не из свежего запроса — квест уже завершён к тому моменту

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

  • Сохранить type-specific handlers — каждый новый тип квеста требует изменений на обоих концах
  • GraphQL subscriptions — переусложнение для данного use case

Последствия: Добавление нового типа квеста требует только изменений в плагине и expand-match-criteria.ts. Backend handler не меняется.

Почему ProgressBuffer вместо type-specific буферов?

Проблема (V1): 13 type-specific буферов в PlayerSession (GatherBuffer, CraftBuffer, KillAnimalBuffer, ...). При каждом событии нужно было выбрать правильный буфер, сформировать правильный ключ, при flush — собрать все буферы в events[] BATCH_UPDATE.

Решение (V2): Один Dictionary<string, QuestProgressEntry> ProgressBuffer с ключом userQuestId. Плагин уже сделал matching и знает к какому квесту относится событие.

Guard clause: В начале каждого игрового события плагин проверяет session.ActiveQuests.Any(q => q.Type == "QUEST_TYPE") — если квестов данного типа нет, событие игнорируется без добавления в буфер. Консистентность всех обработчиков событий.

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

  • Оставить type-specific буферы — несовместимо с V2 protocol (плагин должен знать userQuestId)

Последствия: Унифицированная структура данных. Flush — один QUEST_PROGRESS запрос вместо BATCH_UPDATE с вложенными events[].

Почему rate limit на Rust endpoints?

Проблема: /rust/* эндпоинты не имели специфического rate limit. Глобальный лимит /api/* не покрывал /rust/*. Сломанный плагин мог положить backend.

Решение: Per-server rate limit rustWebhook: 400 req/min (ключ: rustServer.id, fallback на IP, увеличен с 200 для серверов с 200+ игроками). Нормальная нагрузка после оптимизации ~22 req/min, лимит с запасом ~18×.

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

  • Глобальный IP rate limit — не различает серверы, ложные срабатывания
  • Отсутствие лимита — сломанный плагин без ограничений

Последствия: Защита от аварий. При 429 плагин получает retryAfter.

Почему события буферизируются в плагине?

Проблема: High-frequency события создают огромную нагрузку. Пример: крафт 10× gunpowder генерирует событие каждую секунду. При 100 игроках это ~10,000 HTTP запросов в минуту.

Решение: Буферизация в памяти плагина + периодический batch flush на backend.

Как работает буферизация (V2)

Структура данных (V2):

// Один унифицированный буфер: userQuestId → прогресс
public Dictionary<string, QuestProgressEntry> ProgressBuffer { get; set; }

// V1 имел 13 type-specific буферов:
// GatherBuffer, CraftBuffer, KillAnimalBuffer, ... — заменены ProgressBuffer

Накопление:

// При каждом игровом событии — O(1) Contains + O(1) накопление
// Плагин уже знает userQuestId (из match criteria при CONNECTED)
if (ProgressBuffer.TryGetValue(questId, out var entry))
entry.Increment += amount;
else
ProgressBuffer[questId] = new QuestProgressEntry { Increment = amount };

Flush по таймеру (каждые 60 сек) — QUEST_PROGRESS:

timer.Every(GatherUpdateInterval, SendPeriodicQuestProgress);
// ОДИН запрос на игрока со ВСЕМИ накопленными increments

Immediate flush (мгновенная отправка):

  • При completion квеста (игрок видит результат сразу)
  • При disconnect (данные не теряются)
  • При unload плагина (graceful shutdown)

Расчёт нагрузки (100 игроков):

МетрикаБез буферизацииС буферизацией
HTTP запросов/мин~10,000~100
Нагрузка на APIКритичнаяМинимальная
Задержка данных0 сек≤60 сек

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

  • Каждое событие = HTTP запрос — перегрузка API, неприемлемо
  • Уменьшить interval до 10 сек — x6 нагрузка без реальной пользы для UX
  • WebSocket вместо HTTP — переусложнение, Rust плагины не поддерживают нативно

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

  • Максимальная задержка синхронизации: 60 секунд (настраивается через GatherUpdateInterval)
  • /goloot показывает реальный прогресс: backend_value + local_buffer
  • Данные защищены от потери: pending save каждые 120 сек, flush при disconnect
Паттерн

Это стандартный паттерн для high-frequency telemetry: accumulate → batch → flush. Используется в analytics SDK, game telemetry, metrics collectors.

Dead Letter Queue + Circuit Breaker

Retry: exponential backoff (5s, 10s, 20s ±25% jitter, cap 60s), 3 попытки. Если все исчерпаны — payload в in-memory DLQ. Таймер каждые 30 сек переотправляет (FIFO, по одному). TTL = 30 мин.

Circuit breaker: после 5 consecutive failures (из любого webhook/batch) новые webhooks идут прямо в DLQ, минуя retry chain. DLQ timer действует как probe — при успехе counter сбрасывается, circuit закрывается. Предотвращает лавину retry при deploy/downtime.

Подробнее: ADR: Почему Dead Letter Queue

Почему модификаторы добычи засчитываются?

Проблема: При добыче ресурсов плагин получает финальное количество (item.amount) после применения всех модификаторов:

  • Игровые модификаторы (Tea buff: +50% к добыче)
  • Серверные плагины (GatherManager, QuickSmelt с x2-x10 рейтами)

Вопрос: считать "честное" базовое значение или финальное с модификаторами?

Решение: Засчитывать финальное значение (все модификаторы учитываются).

Почему так:

  1. Архитектура Oxide/uMod:

    • Хуки (OnDispenserGather, OnDispenserBonus) вызываются ПОСЛЕ обработки Rust сервером
    • item.amount уже содержит финальное значение с ВСЕМИ модификаторами (Tea, IQRates, GatherManager)
    • Невозможно отличить Tea buff от плагинов рейтов — оба влияют на item.amount
    • Эмпирически проверено: при x5 IQRates → before=413 (уже с модификатором), after=0 (item consumed)
    • NextTick() не работает — к тому моменту item уже отдан игроку и amount=0
  2. Справедливость:

    • Игрок использовал ресурс (чай) → заслужил бонус к прогрессу
    • Игрок выбрал сервер с высокими рейтами → это часть игрового опыта
  3. Простота:

    • Не требует дополнительной конфигурации
    • Не требует знания о плагинах рейтов на сервере
    • Квесты работают одинаково на всех серверах

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

ВариантПочему отклонён
Серверный нормализатор (gatherRateMultiplier в RustServer)Требует ручной настройки, легко забыть обновить при смене рейтов
Расчёт базового значения в плагинеНевозможно — Oxide не предоставляет base value
Хук до применения модификаторовOxide вызывает хуки после обработки

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

  • Квесты на серверах с x10 рейтами выполняются в 10 раз быстрее
  • Админы должны учитывать рейты сервера при создании квестов
  • Рекомендация: создавать разные квесты для серверов с разными рейтами
Рекомендации для админов

При создании GATHER квестов для серверов с высокими рейтами:

  • x2 рейты: targetValue * 2 (1000 дерева → 2000)
  • x5 рейты: targetValue * 5
  • Или использовать отдельные квесты для разных категорий серверов

Почему нужен хук OnInstantGatherTriggered?

Проблема: При прокачанном скилле моментальной добычи (Instant Mine/Chop/Skin из SkillTree) квесты на сбор засчитывали только ~10-15% ресурсов. Пример: добыто 1100 металла, засчитано 150.

Причина: SkillTree при срабатывании instant buff полностью обходит стандартные Oxide хуки:

  • OnDispenserGather — НЕ вызывается
  • OnDispenserBonus — НЕ вызывается

Вместо этого SkillTree напрямую:

  1. Извлекает все ресурсы из dispenser.containedItems
  2. Применяет множители (yield, night bonus, luck)
  3. Выдаёт предметы через GiveItem()
  4. Уничтожает ноду (health = 0)

Решение: Подписаться на кастомный хук SkillTree:

// GoLootTracker.cs
private void OnInstantGatherTriggered(BasePlayer player, ResourceDispenser dispenser, Item item, string pluginName)
{
if (player == null || item == null) return;
AccumulateGather(player, item.info.shortname, item.amount, "DISPENSER");
}

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

ВариантПочему отклонён
Патчить SkillTreeЭто сторонний плагин, нет контроля над обновлениями
Использовать OnItemAddedToContainerЛовит ВСЕ предметы, включая лут и крафт — сложная фильтрация
Отслеживать уничтожение нодыНе даёт информацию о полученных ресурсах

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

  • Все ресурсы от instant mining засчитываются корректно
  • Работает с любыми множителями SkillTree (yield, luck, night bonus)
  • Требует: SkillTree должен вызывать OnInstantGatherTriggered (уже делает в текущей версии)
Зависимость от SkillTree API

Если SkillTree изменит или уберёт хук OnInstantGatherTriggered — instant mining перестанет засчитываться. Мониторить changelog SkillTree при обновлениях.

Как работает DLC маппинг?

Проблема: DLC скины имеют уникальные shortnames, отличающиеся от базового предмета:

  • rocket.launcher — базовый RPG
  • rocket.launcher.dragon — DLC "Frightening Dragon"
  • rocket.launcher.rpg7 — DLC "RPG-7"

Квест "Скрафти 5× RPG" с rustCraftItemShortname = "rocket.launcher" не засчитывал DLC варианты.

Решение: Использовать официальный Rust API ItemDefinition.isRedirectOf + fallback маппинг.

Как это работает:

┌─────────────────────────────────┐
│ ItemDefinition │
│ (rocket.launcher.dragon) │
│ │
│ shortname: "rocket.launcher.dragon"
│ isRedirectOf: ──────────────────┼──► ItemDefinition (rocket.launcher)
└─────────────────────────────────┘

Facepunch при создании DLC предмета должен устанавливать isRedirectOf на базовый ItemDefinition. Однако не все DLC имеют это поле (баг/недоработка Facepunch).

Известные DLC без isRedirectOf
  • rocket.launcher.rpg7 — обнаружено эмпирически 2025-02
  • Другие добавлять по мере обнаружения в DlcFallbackMapping

Метод в плагине:

// Fallback для DLC где Facepunch не заполнил isRedirectOf
private static readonly Dictionary<string, string> DlcFallbackMapping =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "rocket.launcher.rpg7", "rocket.launcher" },
// Добавлять другие DLC по мере обнаружения
};

private string GetBaseShortname(ItemDefinition itemDef)
{
// Приоритет 1: Официальный Rust API
if (itemDef.isRedirectOf != null)
return itemDef.isRedirectOf.shortname.ToLower();

// Приоритет 2: Fallback маппинг
string shortname = itemDef.shortname.ToLower();
if (DlcFallbackMapping.TryGetValue(shortname, out string baseShortname))
return baseShortname;

return shortname;
}

Покрытие в плагине:

EventМетодDLC маппинг
CRAFTGetBaseShortname(item.info)
RECYCLEGetBaseShortname(item.info)
KILL_ANIMAL/SCIENTIST (weapon)GetWeaponShortname()GetBaseShortname()
LOOT_ITEMGetBaseShortname(itemDef)✅ (DLC не дропается из контейнеров, но защита есть)
GATHER (resources/tools)Не нуженDLC версий ресурсов/инструментов не существует
FISHНе нуженDLC версий рыбы не существует

Почему гибридный подход:

  • isRedirectOf — основной источник, автоматически работает для ~95% DLC
  • DlcFallbackMapping — резервный вариант для DLC, где Facepunch забыл заполнить поле
  • Fallback применяется ко всем событиям через единую точку входа GetBaseShortname()

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

  • Автоматическая поддержка большинства DLC через isRedirectOf
  • Ручной fallback для проблемных DLC (добавлять по мере обнаружения)
  • Квесты на базовые предметы засчитывают DLC варианты
Как обнаружить проблемный DLC

В логах плагина будет: [CRAFT_MAPPED] raw=xxx.yyy → mapped=xxx.yyy (без изменения). Если shortname не изменился — добавить в DlcFallbackMapping.

Почему дифференцированные retention periods?

Проблема: Webhook логи накапливаются очень быстро. На сервере с 100 игроками:

Батчинг отправляет только при наличии данных

Пустые батчи (GATHER, LOOT, FISH, etc.) не отправляются — webhook создаётся только если буфер содержит события. TIME data отправляется как часть QUEST_PROGRESS (интервал настраивается в плагине, по умолчанию 10 мин).

Реалистичная нагрузка (зависит от активности игроков):

АктивностьИгроковWebhook/игрок/часВсего/час
AFK/база (только QUEST_PROGRESS с TIME data)4012480
Обычная игра (строительство, редкий фарм)40301,200
Активный гринд (квесты, фарм, крафт)20701,400
TOTAL100~3,080/час

В день: ~74k записей (~1.3 GB с индексами и payload)

Пиковая нагрузка (все игроки активны): ~7,000 webhook/час (~168k/день)

Без cleanup БД вырастет до десятков GB за месяц.

Решение: Дифференцированные retention periods в зависимости от статуса лога:

СтатусRetentionПричина
PROCESSED14 днейУспешные события — для аудита и дебага
FAILED30 днейОшибки требуют длительного исследования
PENDING1 деньЗависшие события — аномалия требующая внимания

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

  • Единый retention 14 дней для всех — теряем FAILED логи для debug
  • Партиционирование таблицы — переусложнение, Prisma не поддерживает нативно
  • Архивация в S3 — дополнительная инфраструктура, избыточно для логов
  • Без cleanup — БД вырастет до критического размера

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

  • Стабилизация БД на ~15 GB (14 дней PROCESSED + 30 дней FAILED)
  • FAILED логи доступны месяц для анализа patterns
  • Warning при >100 PENDING старше 1 дня — индикатор проблем с обработкой
PENDING Alert

Если cleanup находит >100 зависших PENDING логов старше 1 дня — это сигнал о системной проблеме:

  • Сбой webhook processing service
  • Deadlock в обработке
  • Неожиданный формат payload

Требуется ручное расследование через Admin Panel → Rust Server Logs.

Почему Dead Letter Queue (in-memory)?

Проблема: При отправке webhook из плагина, если бэкенд недоступен (HTTP 0) и все 3 retry провалились (3 × 5 сек = 15 секунд) — данные теряются навсегда. Буфер уже очищен (Buffer.Clear() вызывается до получения ответа), payload существует только в замыкании retry.

Решение: In-memory Dead Letter Queue (DLQ) в SendWebhook().

SendWebhook fail + retry exhausted
→ сохранить payload в List<FailedWebhook>

Timer каждые 30 сек
→ если DLQ не пуст → отправить первый элемент (FIFO)
→ успех → удалить, продолжить со следующим
→ fail → остановиться, retry на следующем тике
ПараметрЗначениеОписание
DLQ Timer30 секИнтервал проверки очереди
TTL10 минМаксимальный возраст элемента в DLQ
Retry strategyПо одномуНе спамить backend, ждать восстановления

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

  • Файловая персистентность — disk I/O на игровом сервере вызывает лаги; если сервер крашится — данные потеряны независимо от DLQ; KISS
  • Batch retry — если backend ещё не ожил, batch усугубляет нагрузку
  • Увеличить retry count — откладывает проблему, не решает (при длительном downtime все retry fail)

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

  • Покрывает 99% случаев кратковременного сбоя (секунды-минуты)
  • DLQ работает на уровне SendWebhook — автоматически покрывает ВСЕ типы буферизованных событий
  • Dedup безопасность: DLQ отправляет оригинальный webhookId из замыкания (сохранён в data dict), DB constraint P2002 блокирует дубли при повторной отправке
  • При unload плагина с непустой DLQ — warning в логах
TTL = 10 минут

Если backend лежит > 10 минут — это инцидент, требующий ручного вмешательства. DLQ элементы старше 10 мин удаляются с warning логом. Дубли при повторной отправке блокируются DB constraint (P2002) по deduplicationKey.

Почему database-level дедупликация вместо SELECT-then-INSERT?

Проблема: Старый подход (SELECT проверка → INSERT) создавал несколько проблем:

  1. TOCTOU race condition — два одинаковых webhook-а проходят проверку одновременно до того, как любой из них создаст запись
  2. JSON path сканирование без индекса — медленные запросы по payload->>'timestamp' по всей таблице
  3. Ручной маппинг getContentKeyField() — требует обновления при каждом новом типе события
  4. 2 DB операции на каждый webhook — SELECT + INSERT

Решение: Колонка deduplicationKey с partial unique index:

CREATE UNIQUE INDEX "rust_webhook_logs_dedup_key_unique"
ON "rust_webhook_logs" ("deduplicationKey")
WHERE "deduplicationKey" IS NOT NULL;

INSERT с P2002 catch вместо SELECT-then-INSERT. Паттерн P2002 уже используется в кодовой базе (admin-season-lifecycle.controller.ts, utm-campaign.service.ts).

Вычисление deduplicationKey:

  • Plugin v2webhookId): ключ = data.webhookId (UUID, глобально уникален)
  • Plugin v1 (fallback): ключ = {steamId}:{eventType}:{timestamp}:{contentKey}

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

  • SELECT-then-INSERT — не атомарно, TOCTOU race condition сохраняется
  • Application-level mutex (per-server lock) — не масштабируется горизонтально

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

МетрикаДо (SELECT+INSERT)После (P2002)
Race conditionВозможенНевозможен (атомарно)
DB calls на webhook21 (при дубле: 1 с P2002 ошибкой)
Новые event typesНужен getContentKeyField()Автоматически (webhookId)
DLQ retry дублиНовый timestamp = пропускаетwebhookId сохраняется → P2002
Аудит дублейНет (silent return)Logger debug с deduplicationKey

Почему webhookId помещается в data dict, а не в payload?

Проблема: DLQ (Dead Letter Queue) сохраняет webhook-и как FailedWebhook { Data, EventType, SteamId, Timestamp }. При retry через SendWebhook() payload пересоздаётся с новым timestamp. Если уникальный ID хранится только в payload верхнего уровня — при retry он теряется.

Решение: Инжекция webhookId внутрь data dict в методе SendWebhook():

if (!data.ContainsKey("webhookId"))
data["webhookId"] = Guid.NewGuid().ToString();

data dict сохраняется в DLQ as-is. При retry SendWebhook() получает тот же data с webhookId → backend получает тот же dedup ключ → P2002 блокирует дубль.

Почему не в payload верхнего уровня: DLQ сохраняет только Data dict, payload при retry пересоздаётся с новым timestamp.

Последствия: 1 изменение в SendWebhook() автоматически покрывает все 30+ call sites без изменений.

Backward Compatibility

Plugin v1 (без webhookId): fallback computed key {steamId}:{eventType}:{timestamp}:{contentKey} обеспечивает защиту. Plugin v2 (с webhookId): UUID как ключ — гарантированная уникальность независимо от timestamp.

СценарийПоведение
Plugin v1 (без webhookId)Computed fallback key — P2002 защита работает
Plugin v2 (с webhookId)UUID как ключ — гарантированная уникальность
DLQ retry (v2)webhookId сохранён в data — P2002 блокирует дубль
DLQ retry (v1)Новый timestamp — новый computed key — возможен дубль (не регрессия, текущее поведение)
Одновременные webhook-ыDB unique constraint — атомарная блокировка, TOCTOU невозможен
Старые записи без deduplicationKeyNULL — не попадают под partial unique index

Почему rustParams вместо 37 отдельных колонок?

Проблема: Quest model содержал 37 nullable rust* колонок для конфигурации Rust квестов (rustFishType, rustTargetMinutes, rustCraftItemShortname и т.д.). Большинство квестов использовали только 2-4 поля из 37 — остальные NULL.

Решение: Консолидация 37 колонок в один rustParams Json? столбец. Quest model: 39 rust columns → 3 (rustServerId, rustEventType, rustParams). Сокращение на 92%.

Что осталось колонками:

  • rustServerId String? — FK с index, используется в WHERE
  • rustEventType RustQuestEventType? — discriminator, используется в WHERE и raw SQL (titles)

Что осталось в UserQuest: 15 progress полей (rustMinutesPlayed, rustCommandCount, rustLootCount и др.) — атомарный { increment: x } в Prisma не работает с JSON.

Naming convention: Внутри rustParams поля без rust prefix: rustFishTyperustParams.fishType.

Type Safety:

  • Запись (create/update/import): Zod schema rustQuestParamsSchema.parse(data.rustParams)
  • Чтение: Type assertion getRustParams(quest.rustParams): RustQuestParams | null
  • Zod parse на каждом чтении НЕ нужен — данные валидируются на записи

API response: Остаётся FLAT с rust prefix (rustFishType, rustTargetMinutes) — frontend не меняется. Backend распаковывает rustParams → плоские поля в response.

Admin API request: Nested формат — rustParams: { fishType: "ANY", fishCount: 3 }.

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

  • Оставить 37 колонок — column bloat, пустые nullable поля, тяжёлый SELECT
  • Менять API response на nested — breaking change для frontend
  • Zod parse на каждом чтении — overhead без пользы (данные уже валидны)

Последствия: Quest model стал компактнее (70 → 28 полей), упростился CRUD (1 поле вместо 37 conditional assignments), fixtures читабельнее (nested vs flat).

Почему InFlight Guard?

Проблема: Два игровых события одного типа (например, два COLLECTIBLE pickup) вызывают два SendImmediateXxxUpdate, которые порождают два concurrent webrequest.Enqueue. Oxide HTTP client возвращает HTTP 0 на второй concurrent запрос того же типа.

Корневая причина: Нет механизма предотвращения concurrent webhook sends одного event type per player. Quest cache (ActiveQuests) обновляется асинхронно (по ответу webhook), поэтому второе событие тоже детектирует "quest completed" и шлёт запрос до получения ответа на первый.

Решение: HashSet<string> InFlightEvents в PlayerSession — at most one in-flight HTTP request per event type per player. Данные накапливаются в ProgressBuffer, пока запрос в полёте. По получению ответа — re-check буфера на новые данные.

V2 event keys: QUEST_PROGRESS (единый ключ для всех игровых событий, включая TIME data). В V1 было 11 отдельных ключей (GATHER, LOOT, KILL_ANIMAL, ...) + отдельный TIME_UPDATE — в V2 все консолидированы в один.

Ключевые решения:

РешениеОбоснование
buffer.Clear() ДО SendWebhookSendWebhook сериализует JSON синхронно (JsonConvert.SerializeObject) — данные захвачены в строке до webrequest.Enqueue. Ранняя очистка позволяет новым событиям сразу накапливаться
Re-check только при success=trueПредотвращает бесконечный retry loop при падении backend. На failure — periodic batch (60s) подхватывает остаток
Player lookup в callbacksМетоды, требующие BasePlayer, используют BasePlayer.FindByID(session.SteamId) в callback — оригинальная ссылка на player может быть stale. Если игрок отключился — skip (FlushAllBuffers уже обработал)
FlushAllBuffers очищает InFlightDisconnect = must send all data. Редкий concurrent request при disconnect приемлем (retry handles it, webhookId обеспечивает идемпотентность)

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

  • Глобальный mutex на все типы — блокировал бы независимые event types друг от друга
  • Queue per event type — переусложнение, буферы уже выполняют роль очереди

Последствия: Устраняет HTTP 0 при concurrent sends. Данные не теряются — накапливаются в буфере и отправляются после завершения in-flight запроса. Подробная архитектура: ADR-012: Rust Webhook Sync Architecture.


4. Architecture

Plugin Startup Flow

Зачем отдельный health check?

Раньше плагин отправлял фейковый HEARTBEAT на POST /rust/webhook для проверки связи, что возвращало 400 (неизвестный event type) и создавало шум в логах. GET /rust/health --- lightweight endpoint без записи в БД, только проверка auth + статус сервера.

Webhook Flow

V2 отличие от V1

В V1 диаграмма содержала loop For each matching quest с matchesConditions() для каждого события. В V2 matching отсутствует на backend — плагин уже определил userQuestId, backend только применяет increment через applyProgressIncrement.

Batch Webhook Flow (Server-Side Batching)

Plugin Webhook Delivery (with DLQ)

Session Management

Key Components

КомпонентПутьОписание
WebhookControllerbackend/src/domains/rust-integration/controllers/webhook.controller.tsHTTP handlers
WebhookServicebackend/src/domains/rust-integration/services/webhook.service.tsРоутинг per-player событий (CONNECTED, DISCONNECTED)
WebhookBatchServicebackend/src/domains/rust-integration/services/webhook-batch.service.tsServer-side batching: дедупликация, chunk processing (50), per-player logs, crash recovery
WebhookHandlersbackend/src/domains/rust-integration/services/webhook-handlers.service.ts3 обработчика: CONNECTED, DISCONNECTED, QUEST_PROGRESS (переиспользуется batch service)
RustQuestProgressServicebackend/src/domains/rust-integration/services/rust-quest-progress.service.tsОбновление прогресса квестов: applyProgressIncrement, updateTimeProgress, getActiveRustQuests
PlayerSessionServicebackend/src/domains/rust-integration/services/player-session.service.tsУправление сессиями
SteamLinkingServicebackend/src/domains/rust-integration/services/steam-linking.service.tsSteam ↔ User mapping (Redis cache, TTL 5 min)
WebhookCleanupJobbackend/src/domains/rust-integration/jobs/webhook-cleanup.job.tsАвтоматическая очистка старых логов (daily at 03:00 UTC)
Plugin Auth Middlewarebackend/src/domains/rust-integration/middleware/plugin-auth.middleware.tsAPI Key verification
Webhook Routesbackend/src/domains/rust-integration/routes/webhook.routes.tsPlugin API
Admin Routesbackend/src/domains/rust-integration/routes/admin-rust.routes.tsServer management
Admin UI: Server Detailadmin/src/pages/RustServerDetail.tsxPlugin config (API Key reveal с auto-hide), webhook logs с clickable фильтрами и payload summary
QuestMapperbackend/src/domains/rust-integration/mappers/quest-mapper.tsV2: возвращает { userQuestId, match: QuestMatchCriteria }. mapCompletedQuests enforces current >= target через Math.max
ExpandMatchCriteriabackend/src/domains/rust-integration/utils/expand-match-criteria.tsРаскрытие категорий квестов в конкретные значения для plugin-side matching. Вызывается при PLAYER_CONNECTED
SkillTree Constantsbackend/src/domains/rust-integration/constants/skill-tree.tsКонстанты SkillTree деревьев и скиллов
Webhook Schemasbackend/src/domains/rust-integration/schemas/webhook.schemas.tsВалидация webhook payload (V2: 3 event types)
Admin Schemasbackend/src/domains/rust-integration/schemas/admin-rust.schemas.ts, admin-rust-server.schemas.tsВалидация admin API
Webhook Swagger Schemasbackend/src/domains/rust-integration/schemas/webhook-swagger.schemas.tsOpenAPI документация
Typesbackend/src/domains/rust-integration/types/rust.types.tsV2: RustWebhookEventType (3 типа), QuestMatchCriteria, RustEventData, QuestAction

5. Database Schema

Models

МодельОписаниеКлючевые поля
RustServerКонфигурация сервераname, apiKey, ipAddress, isActive
RustWebhookLogЛог webhook-ов (аудит)serverId, eventType, steamId, payload, status, deduplicationKey, parentBatchId
RustPlayerSessionСессия игрокаserverId, userId, steamId, connectedAt, sessionCreatedAt, totalMinutes, isActive

RustWebhookLog Statuses

СтатусОписание
PENDINGПолучен, ожидает обработки
PROCESSINGBatch в процессе обработки (server-side batching)
PROCESSEDУспешно обработан
FAILEDОшибка при обработке
DUPLICATEДубликат (идемпотентность)
IGNOREDИгнорирован (нет активных квестов)

Webhook Log Retention Policy

Автоматическая очистка

WebhookCleanupJob выполняется ежедневно в 03:00 UTC для предотвращения неконтролируемого роста БД.

Retention periods:

  • PROCESSED логи: 14 дней
  • FAILED логи: 30 дней (для исследования проблем)
  • PENDING логи: 1 день (зависшие события)

Производительность:

  • Рост БД без cleanup: ~1.5 GB/день (высоконагруженный сервер)
  • После cleanup: стабилизация на ~15 GB (14 дней логов)
  • Проверка зависших: если >100 PENDING старше 1 дня → warning в логи

Ручной запуск:

const job = new WebhookCleanupJob();
await job.runNow(); // { totalDeleted: 50000 }

Relationships

Batch Log Hierarchy

QUEST_PROGRESS_BATCH создаёт 1 batch summary log (eventType=QUEST_PROGRESS_BATCH) + N per-player logs (eventType=QUEST_PROGRESS, parentBatchId → batch log). Это сохраняет Admin DX — per-player логи фильтруются и отображаются как обычные QUEST_PROGRESS.

Key Indexes

rust_servers:
@@index([apiKey]) -- Быстрый lookup при auth
@@index([isActive]) -- Фильтрация активных серверов

rust_webhook_logs:
@@index([serverId]) -- Логи по серверу
@@index([steamId]) -- Логи по игроку
@@index([eventType]) -- Фильтрация по типу события
@@index([status]) -- Фильтрация по статусу
@@index([createdAt]) -- Хронологическая сортировка
@@index([parentBatchId]) -- Дочерние логи batch'а
UNIQUE INDEX "dedup_key" ON "rust_webhook_logs" ("deduplicationKey") WHERE "deduplicationKey" IS NOT NULL -- Partial unique: DB-level дедупликация (P2002)

rust_player_sessions:
@@index([serverId, isActive]) -- Активные сессии на сервере
@@index([userId, isActive]) -- Активные сессии пользователя
@@index([steamId, isActive]) -- Быстрый lookup по Steam ID
@@index([connectedAt]) -- Хронологическая сортировка сессий

6. API Endpoints

МетодЭндпоинтОписаниеAuth
GET/rust/healthHealth check: проверка auth + активности сервера. Без записи в БДBearer API Key
POST/rust/webhookОбработка события от плагина (PLAYER_CONNECTED, PLAYER_DISCONNECTED, QUEST_PROGRESS)Bearer API Key
POST/rust/webhook/batchБатчевый QUEST_PROGRESS — все игроки одним запросом, ответ содержит activeQuestsBearer API Key
GET/rust/tasks/:steamIdАктивные квесты одного игрока (используется /goloot chat command)Bearer API Key
Health Check

Плагин вызывает GET /rust/health при запуске для проверки подключения к бекенду и валидности API Key. Заменяет старый подход с отправкой фейкового HEARTBEAT события на POST /rust/webhook (который возвращал 400).

Response 200:

{
"success": true,
"server": { "id": "server-uuid", "name": "My Rust Server" }
}

Errors: 401 (невалидный токен), 403 (сервер деактивирован)

Server-Side Batching: POST /rust/webhook/batch

Плагин отправляет один POST /rust/webhook/batch каждые 60 секунд, содержащий прогресс ВСЕХ активных игроков. Ответ содержит completedQuests и activeQuests для каждого игрока, заменяя отдельный механизм синхронизации (SyncAllPlayerQuestState и POST /rust/tasks/batch удалены).

Request:

{
"serverId": "server-uuid",
"eventType": "QUEST_PROGRESS_BATCH",
"timestamp": "2026-03-16T12:00:00.000Z",
"webhookId": "batch-uuid",
"players": {
"76561198175539447": {
"updates": [
{ "userQuestId": "q1", "increment": 5, "actions": [{"t":"GATHER","d":"wood","n":5}] },
{ "userQuestId": "q2", "absoluteValue": 3 }
],
"minutesPlayed": 121,
"pendingMinutes": 0,
"playerName": "Soda"
}
},
"syncPlayers": ["76561198000009999"]
}

Response 200:

{
"success": true,
"batchId": "log-id",
"results": {
"76561198175539447": {
"completedQuests": [{ "userQuestId": "q1", "questId": "...", "title": "Gather 100 wood" }],
"activeQuests": [{ "userQuestId": "q2", "type": "SKILL_MAXED", "target": 5, "currentProgress": 3, "match": { "trees": ["*"] } }]
}
},
"stats": { "total": 1, "processed": 1, "failed": 0, "skipped": 0, "synced": 1 }
}

syncPlayers (optional) — массив steamId игроков, которым нужна только синхронизация activeQuests без обработки прогресса. Используется для игроков, у которых не было quest progress за текущий цикл, но плагину нужно получить актуальный список квестов (например, после accept/abandon квеста через Mini App). SyncPlayers не создают per-player webhook logs и не участвуют в progress processing. Если steamId присутствует и в players, и в syncPlayers — приоритет у players (дедупликация). В ответе results содержат activeQuests для sync-игроков (с пустым completedQuests). Статистика stats.synced отражает количество обработанных sync-игроков.

Эффект: HTTP requests 201/min → 1/min. Rate limit headroom 2× → 400×.

Backend processing:

  1. Дедупликация по webhookId (INSERT с status=PROCESSING, P2002 catch)
  2. Bulk resolve users: один findMany для всех steamIds
  3. Создание per-player logs через createMany (eventType=QUEST_PROGRESS, parentBatchId=batchLogId)
  4. Chunk processing: 50 players per chunk, Promise.allSettledhandleQuestProgress() per-player 4.5. Sync questless players: bulk fetch activeQuests для syncPlayers (без логов, без progress processing)
  5. Финализация: batch log → PROCESSED, per-player logs обновлены

PLAYER_CONNECTED / PLAYER_DISCONNECTED остаются per-player через POST /rust/webhook (редкие lifecycle events).

QUEST_PROGRESS webhook (V2)

Плагин делает plugin-side matching и шлёт QUEST_PROGRESS с готовыми increments. Каждый update может содержать actions — контекст действий (short keys t/d/x/n, см. Action Context).

Payload:

{
"eventType": "QUEST_PROGRESS",
"steamId": "76561198000001",
"data": {
"webhookId": "uuid-v4",
"minutesPlayed": 45,
"pendingMinutes": 0,
"updates": [
{
"userQuestId": "uq_abc123",
"increment": 150,
"actions": [
{ "t": "GATHER", "d": "wood", "x": "stone.pickaxe", "n": 100 },
{ "t": "GATHER", "d": "wood", "x": "rock", "n": 50 }
]
},
{ "userQuestId": "uq_def456", "increment": 1 },
{ "userQuestId": "uq_ghi789", "absoluteValue": 5 }
]
}
}
  • absoluteValue — для Ultimate квестов (useAbsolute: true в match criteria) — SET вместо INCREMENT
  • actions — опциональный audit trail, автосохраняется в webhook log payload
Формат ответа webhook (V2)
{
"success": true,
"message": "QUEST_PROGRESS: 3 updates, 1 completed, 0 skipped",
"data": {
"completedQuests": [
{
"userQuestId": "uq_abc123",
"questId": "q_456",
"title": "Добудь 1000 дерева",
"type": "GATHER",
"target": 1000,
"currentProgress": 1000,
"match": { "resources": ["wood"], "methods": ["*"] }
}
],
"activeQuests": [
{
"userQuestId": "uq_def456",
"questId": "q_789",
"title": "Убей 5 медведей",
"type": "KILL_ANIMAL",
"target": 5,
"currentProgress": 2,
"match": { "animals": ["BEAR"] }
}
]
}
}

completedQuests и activeQuests — одинаковая структура (MappedQuest): userQuestId, questId, title, type, target, currentProgress, match. Для completedQuests гарантируется currentProgress >= target через mapCompletedQuests.


Plugin Configuration

Log Levels

Плагин GoLootTracker поддерживает настраиваемые уровни логирования через конфиг и консольную команду.

Команда: goloot.loglevel <0-2>

LevelНазваниеОписаниеПример вывода
0OFFЛогирование отключено
1INFOБизнес-события (default)[GoLootTracker] [QUARRY] Player gathered 5x hq.metal.ore
2DEBUGINFO + диагностика[GoLootTracker] [DEBUG] Sending GATHER updates: 3 entries

Конфиг (oxide/config/GoLootTracker.json):

{
"Log Level": 1
}

INFO (Level 1) — production default:

  • Подключение/отключение игроков
  • Квестовые действия: GATHER, CRAFT, KILL, LOOT, RECYCLE, FISH, EXPLOSIVE
  • Завершение квестов
  • Инициализация плагина

DEBUG (Level 2) — для диагностики:

  • Отправка буферов на backend
  • Webhook запросы и ответы
  • Инициализация сессий
  • AFK статус изменения
  • Синхронизация квестов
Когда использовать DEBUG

Включай Level 2 когда:

  • Квест не засчитывается и нужно понять почему
  • Проверить что webhook доходит до backend
  • Диагностика проблем с сессиями

  • Quests — RUST квесты как категория квестов
  • Steam Verification — верификация Steam аккаунта для Rust квестов
  • Seasons — прогресс квестов только при ACTIVE сезоне