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
Описанные ниже 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} пары.
- Session Events
- Action Events
- Loot Events
- Kill Events
- Craft Events
- Recycle Events
- Explosive Events
- Skill Events
- Farming Events
- Batch Events
PLAYER_CONNECTED — Игрок подключился к серверу
- Создаёт сессию
RustPlayerSession(lazy: только если есть активные квесты) - Возвращает список активных квестов для плагина
PLAYER_DISCONNECTED — Игрок отключился
- Закрывает активную сессию
- Финализирует прогресс TIME квестов
TIME data в QUEST_PROGRESS — Периодическое обновление времени передаётся как часть QUEST_PROGRESS (поля minutesPlayed, pendingMinutes)
- Обновляет
totalMinutesв сессии - Увеличивает прогресс TIME квестов
- Поддерживает
pendingMinutesдля recovery после краша - Timestamp-based calculation: сравнивает
userQuest.startedAtvssession.connectedAtдля правильного расчёта времени
Если игрок взял квест "Наиграй 5 минут" после 200 минут игры, засчитывается только время с момента взятия квеста, а не всё время сессии. Это предотвращает автоматическое выполнение квестов при их получении.
Lazy Session Creation Fix: При создании сессии "лениво" (после взятия квеста), система ищет PLAYER_CONNECTED webhook в логах для определения реального времени подключения. Поле sessionCreatedAt хранит время создания записи в БД для аудита, а connectedAt — реальное время подключения игрока.
COMMAND_EXECUTED — Игрок выполнил команду
- Команда нормализуется (lowercase, без ведущих
/) - Строгое сравнение с
rustTargetCommand - Атомарный increment счётчика выполнений
RESOURCE_GATHERED — Игрок собрал ресурсы
- Поддержка категорий:
ORE,ANY, или конкретный shortname - Фильтрация по способу добычи:
DISPENSER,COLLECTIBLE,QUARRY,EXCAVATOR,ANY_QUARRY - Мета-категория
ANY_QUARRY=QUARRY+EXCAVATOR(Mining Quarry и Giant Excavator). Раскрывается backend черезexpandGatherMethods()приPLAYER_CONNECTED: плагин получает["QUARRY", "EXCAVATOR"]вmethods[]. КонкретныеQUARRY/EXCAVATORтаргетируют только один тип.
При прокачанном скилле моментальной добычи (Instant Mine/Chop/Skin) стандартные Oxide хуки OnDispenserGather и OnDispenserBonus НЕ вызываются — SkillTree обходит их и выдаёт ресурсы напрямую.
Решение: GoLootTracker перехватывает кастомный хук OnInstantGatherTriggered, который SkillTree вызывает для каждого предмета при срабатывании instant buff.
// Кастомный хук SkillTree (не стандартный Oxide!)
private void OnInstantGatherTriggered(BasePlayer player, ResourceDispenser dispenser, Item item, string pluginName)
Важно: Без этого хука квесты на сбор засчитывают только обычные удары, но не ресурсы от instant mining.
Количество ресурсов (amount) приходит ПОСЛЕ применения всех модификаторов:
- ✅ Tea buff (+30-50% к добыче) — засчитывается
- ✅ Серверные плагины рейтов (GatherManager x2-x10) — засчитываются
Это by design: Oxide/uMod вызывает хуки после обработки сервером, невозможно получить базовое значение.
Для админов: При создании GATHER квестов учитывайте рейты сервера. Подробнее: ADR: Почему модификаторы засчитываются
Ресурсы с Mining Quarry и Giant Excavator засчитываются только пока игрок онлайн.
Почему: Используются нативные хуки OnQuarryGather/OnExcavatorGather (IQRates-подход) вместо ненадёжного OnItemRemovedFromContainer. Хуки засчитывают ресурсы оператору (тот кто включил), но BasePlayer.FindByID() находит только онлайн-игроков.
Fallback: Для Quarry — если оператор оффлайн, используется OwnerID (владелец). Для Excavator — fallback отсутствует (требует постоянного присутствия).
Для админов: Квесты типа "Добудь 1000 sulfur.ore с QUARRY" рассчитаны на активную игру, а не AFK-фарм.
Детали: трекинг оператора и защита от лут-стиллинга
Трекинг по production tick, не по container open
Прогресс квеста засчитывается в момент production tick (когда карьер/экскаватор произвёл ресурс), а не при открытии контейнера. Используется delta counting: afterAmount - beforeAmount в hopper контейнере через NextTick для корректного учёта серверных рейтов (IQRates).
Лут-стиллинг не влияет на прогресс квестов
Если другой игрок откроет контейнер карьера и заберёт ресурсы раньше оператора — прогресс квеста всё равно засчитан оператору. Ресурсы кредитуются на каждый production tick, до того как кто-либо откроет контейнер.
| Сценарий | Физический лут | Прогресс квеста |
|---|---|---|
| Оператор забрал сам | Оператор получил ресурсы | ✅ Засчитан оператору |
| Другой игрок забрал | Другой игрок получил ресурсы | ✅ Засчитан оператору |
| Никто не забрал (контейнер переполнен) | Ресурсы в контейнере | ✅ Засчитан оператору |
Персистентность операторов
Маппинг quarry.net.ID → steamId оператора сохраняется на диск (GoLootTracker/QuarryOperators) и переживает рестарт сервера. Очищается при wipe (OnNewSave). Это позволяет засчитывать ресурсы даже если оператор отключился и подключился заново (при условии что он онлайн в момент production tick).
CONTAINER_LOOTED — Игрок открыл/разбил контейнер
- Мета-категории:
ANY_BARREL,ANY_CRATE,ANY_BASIC,ANY_UNDERWATER,ANY_UWL,ANY_SPECIAL,ANY_CODELOCKED - 35+ конкретных типов:
BARREL,CRATE_ELITE,SUPPLY_DROP,HELI_CRATEи другие
ITEM_LOOTED — Игрок взял предмет из контейнера
- Точное совпадение
itemShortname(case-insensitive) - Опциональный фильтр по контейнеру-источнику
При создании LOOT_ITEM квестов в админке доступен picker с каталогом предметов, разделённых на 14 категорий с глобальным поиском.
LOOT_ITEM квесты используют Container Delta для точного подсчёта взятых предметов — включая stackable (патроны, ресурсы, еда).
Как работает:
OnLootEntity→ snapshot содержимого контейнера- Игрок берёт предметы
OnLootEntityEnd→ delta = snapshot − текущее содержимое → засчитывается
Преимущества перед OnItemRemovedFromContainer:
- Корректный подсчёт stackable предметов (патроны, ресурсы)
- Нет проблемы с
item.amount = 0при стакинге - Если игрок положил предмет обратно → delta = 0
Эта же система используется для FISH квестов (SurvivalFishTrap).
ANIMAL_KILLED — Игрок убил животное
Категории:
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
Фильтр по оружию: ANY, MELEE, RANGED, или конкретный shortname
PREDATOR включает 9 животных (все хищники, включая джунглевых и акулу). JUNGLE — подмножество PREDATOR (только 4 джунглевых животных). STAG и CHICKEN не входят ни в одну категорию кроме ANY.
SCIENTIST_KILLED — Игрок убил учёного (NPC) или уничтожил технику
Гранулярные категории (1 prefab = 1 категория, возвращаются плагином):
OIL_RIG— синие учёные на нефтевышкеOIL_RIG_HEAVY— тяжёлые с чинука (locked crate)CARGO_SHIP,TUNNEL_DWELLER,MILITARY_TUNNEL,UNDERWATER,EXCAVATOR,JUNKPILE,PATROL,SILO,ABANDONED_MILITARY_BASEBRADLEY_GUARD— обычные охранники BradleyBRADLEY_HEAVY— тяжёлые у BradleyDEEP_SEA_ISLAND— учёные на островах и Ghost Ships (Deep Sea, March 2026)DEEP_SEA_PTBOAT— патруль PT BoatDEEP_SEA_RHIB— патруль RHIB
Техника (использует ту же систему KILL_SCIENTIST):
BRADLEY_APC— танк Bradley (НЕ охранники — только сам танк!)PATROL_HELICOPTER— патрульный вертолёт
Мета-категории (объединяют несколько гранулярных):
ANY— любой учёныйHEAVY=OIL_RIG_HEAVY+BRADLEY_HEAVYBRADLEY=BRADLEY_GUARD+BRADLEY_HEAVYOIL_RIG_ALL=OIL_RIG+OIL_RIG_HEAVYDEEP_SEA=DEEP_SEA_ISLAND+DEEP_SEA_PTBOAT+DEEP_SEA_RHIBVEHICLES=BRADLEY_APC+PATROL_HELICOPTER
Фильтр по оружию: ANY, MELEE, RANGED, EXPLOSIVE, или конкретный shortname
Для техники доступна категория EXPLOSIVE:
- ammo.grenadelauncher.he, ammo.rocket.basic, ammo.rocket.hv, ammo.rocket.fire
- explosive.timed (C4), explosive.satchel
- grenade.beancan, grenade.f1
- ammo.rifle.explosive
CH47, ROAM, OUTBREAK и ARCTIC были удалены — невозможно выполнить квест.
CH47/ROAM— нельзя убить этих NPCOUTBREAK— использует тот же prefabscientistnpc_junkpile_pistolчто иJUNKPILE, невозможно различитьARCTIC—scientistnpc_roam(Arctic Research Base) не поддерживается — процедурная генерация, редко встречается
Почему ARCTIC не поддерживается, а ABANDONED_MILITARY_BASE — да?
После тестирования на реальных серверах выяснилось:
- Abandoned Military Base: спавнятся только
scientistnpc_roamtethered - Arctic Research Base: спавнятся только
scientistnpc_roam(без "tethered")
Это позволяет однозначно идентифицировать Abandoned Military Base по prefab. Arctic Research Base генерируется процедурно и встречается редко, поэтому не поддерживается.
Поддержка:
scientistnpc_roamtethered→ABANDONED_MILITARY_BASE✅scientistnpc_roam→ не поддерживается (Arctic) ❌
Prefab → Category Mapping
Гранулярные категории:
| Category | Prefabs | Описание |
|---|---|---|
OIL_RIG | scientistnpc_oilrig | Синие учёные на нефтевышке |
OIL_RIG_HEAVY | scientistnpc_heavy | Тяжёлые с чинука (locked crate) |
CARGO_SHIP | scientistnpc_cargo, scientistnpc_cargo_turret_* | Карго |
TUNNEL_DWELLER | npc_tunneldweller* | Обитатели тоннелей |
MILITARY_TUNNEL | scientistnpc_full_* | Военные тоннели |
UNDERWATER | npc_underwaterdweller | Подводные лаборатории |
EXCAVATOR | scientistnpc_excavator | Экскаватор |
JUNKPILE | scientistnpc_junkpile_pistol | Мусорные кучи (включая джунгли) |
PATROL | scientistnpc_patrol* | Патрульные на дорогах |
BRADLEY_GUARD | scientistnpc_bradley | Обычные охранники Bradley |
BRADLEY_HEAVY | scientistnpc_bradley_heavy | Тяжёлые у Bradley |
SILO | scientistnpc_roam_nvg_variant | Ракетная шахта (Nuclear Missile Silo) |
ABANDONED_MILITARY_BASE | scientistnpc_roamtethered | Заброшенная военная база |
DEEP_SEA_ISLAND | scientist2 | Острова и Ghost Ships (Deep Sea) |
DEEP_SEA_PTBOAT | scientistnpc_ptboat | PT Boat патруль |
DEEP_SEA_RHIB | scientistnpc_rhib | RHIB патруль |
Мета-категории (для квестов):
| Category | Включает | Описание |
|---|---|---|
ANY | Все категории | Любой учёный |
HEAVY | OIL_RIG_HEAVY, BRADLEY_HEAVY | Любой тяжёлый |
BRADLEY | BRADLEY_GUARD, BRADLEY_HEAVY | Любой у Bradley |
OIL_RIG_ALL | OIL_RIG, OIL_RIG_HEAVY | Любой на нефтевышке |
DEEP_SEA | DEEP_SEA_ISLAND, DEEP_SEA_PTBOAT, DEEP_SEA_RHIB | Любой Deep Sea |
VEHICLES | BRADLEY_APC, PATROL_HELICOPTER | Любая техника |
Техника:
| Category | Prefabs | Описание |
|---|---|---|
BRADLEY_APC | bradleyapc | Танк Bradley |
PATROL_HELICOPTER | patrolhelicopter | Патрульный вертолёт |
Монументы в Rust генерируются процедурно при создании карты. Не все монументы гарантированно присутствуют на каждой карте!
Проблема: Если создать DAILY квест "Убей 5 NPC на Silo", а Nuclear Missile Silo не сгенерировалась на карте текущего wipe — квест невыполним.
Рекомендации для DAILY квестов:
| Категория | Безопасность | Причина |
|---|---|---|
OIL_RIG, OIL_RIG_HEAVY, OIL_RIG_ALL | ✅ Безопасно | Всегда есть на картах с океаном |
CARGO_SHIP | ✅ Безопасно | Event, не зависит от монументов |
JUNKPILE, PATROL | ✅ Безопасно | Дороги есть на всех картах |
TUNNEL_DWELLER | ✅ Безопасно | Подземные тоннели есть везде |
BRADLEY_GUARD, BRADLEY_HEAVY, BRADLEY | ✅ Безопасно | Launch Site обычно есть |
ANY, HEAVY | ✅ Безопасно | Мета-категории, всегда выполнимы |
SILO | ⚠️ Рискованно | ~50-70% карт, может отсутствовать |
EXCAVATOR | ⚠️ Рискованно | ~80% карт, может отсутствовать |
ABANDONED_MILITARY_BASE | ⚠️ Рискованно | ~50% карт, может отсутствовать |
MILITARY_TUNNEL | ⚠️ Рискованно | ~90% карт, может отсутствовать |
UNDERWATER | ⚠️ Рискованно | Требует diving gear, сложно для новичков |
Для WEEKLY/PERMANENT квестов можно использовать любые категории — больше времени на выполнение.
ITEM_CRAFTED — Игрок скрафтил предмет
- Точное совпадение
craftItemShortname(case-insensitive) - Атомарный increment по количеству
craftAmount - Без категорий — только конкретные shortnames
Примеры квестов:
- Скрафти 1× AK-47:
rustCraftItemShortname = "rifle.ak",rustCraftTargetAmount = 1 - Скрафти 100× патронов 5.56:
rustCraftItemShortname = "ammo.rifle",rustCraftTargetAmount = 100 - Скрафти C4:
rustCraftItemShortname = "explosive.timed",rustCraftTargetAmount = 1
При создании CRAFT квестов доступен выбор из 933 Rust предметов через ItemSelectModal с фильтрацией по 14 категориям (Weapons, Construction, Items, Resources, Attire, Tools, Medical, Food, Ammo, Traps, Misc, Components, Electrical, Fun).
Уровень верстака не отслеживается — игра сама требует нужный workbench для крафта.
CRAFT отслеживает только предметы из crafting queue (очереди крафта).
НЕ отслеживаются:
- Плавка в печке (metal.fragments, metal.refined, charcoal) — использует
OnOvenCookedhook - Scrap — получается из контейнеров и recycler
Отслеживаются:
- Оружие, инструменты, амуниция
- Взрывчатка (C4, ракеты)
- Медицина (бинты, шприцы)
- Gunpowder, Low Grade Fuel (крафтятся из компонентов)
- Компоненты (после изучения на Research Table)
Крафтящиеся компоненты
В vanilla Rust часть компонентов можно изучить на Research Table и крафтить на Workbench.
Крафтятся (после изучения):
| Shortname | Название | Workbench |
|---|---|---|
gears | Шестерни | WB3 |
metalblade | Металлическое лезвие | WB2 |
metalpipe | Металлическая труба | WB3 |
metalspring | Металлическая пружина | WB3 |
roadsigns | Дорожные знаки | WB3 |
sewingkit | Швейный набор | WB3 |
propanetank | Баллон с газом | WB2 |
НЕ крафтятся (loot only):
| Shortname | Название | Причина |
|---|---|---|
riflebody | Корпус винтовки | Только из ящиков |
smgbody | Корпус SMG | Только из ящиков |
semibody | Полу-корпус | Только из ящиков |
techparts | Техмусор | Только из ящиков |
tarp | Брезент | Только из ящиков |
rope | Верёвка | Не изучается |
sheetmetal | Листовой металл | Только из ящиков |
ITEM_RECYCLED — Игрок переработал предмет в Recycler
- Отслеживается через
OnRecyclerToggle+OnItemRecyclehooks - Точное совпадение
recycleItemShortname(case-insensitive) nullshortname = ANY (любой предмет)- Атомарный increment по количеству
recycleAmount - Количество рассчитывается с учётом stackability (как в UltimateLeaderboard)
Фильтр по типу переработчика (rustRecyclerType):
| Тип | Описание | Эффективность | Скорость |
|---|---|---|---|
ANY | Любой переработчик (default) | — | — |
SAFE_ZONE | SafeZone (Outpost, Bandit Camp) | 40% | 8 сек/предмет |
RADTOWN | Монументы (Launch Site, Dome, etc.) | 60% | 5 сек/предмет |
Определение типа: recycler.IsSafezoneRecycler() — возвращает true для SafeZone recyclers.
Примеры квестов:
- Переработай 100 любых предметов:
rustRecycleItemShortname = null,rustRecyclerType = null,rustRecycleTargetAmount = 100 - Переработай 50 труб на SafeZone:
rustRecycleItemShortname = "metalpipe",rustRecyclerType = "SAFE_ZONE",rustRecycleTargetAmount = 50 - Переработай 10 road signs на монументах:
rustRecycleItemShortname = "roadsigns",rustRecyclerType = "RADTOWN",rustRecycleTargetAmount = 10
OnRecyclerToggleзапоминает кто включил recycler (_recyclerToPlayerdictionary)OnItemRecycleполучает игрока по ID recycler, определяет тип переработчика черезIsSafezoneRecycler()- Данные буферизируются с композитным ключом
{shortname}_{safe|rad}и отправляются batch-ом каждые 60 секунд
Scrap, получаемый из recycler, НЕ отслеживается как отдельный предмет — отслеживается исходный предмет, который был переработан.
Популярные предметы для переработки
Компоненты (высокий выход scrap):
| Shortname | Название | Scrap |
|---|---|---|
riflebody | Корпус винтовки | 25 |
smgbody | Корпус SMG | 15 |
semibody | Полу-корпус | 15 |
techparts | Техмусор | 20 |
gears | Шестерни | 10 |
Распространённые предметы:
| Shortname | Название | Scrap |
|---|---|---|
roadsigns | Дорожные знаки | 5 |
metalpipe | Металлическая труба | 5 |
metalspring | Металлическая пружина | 10 |
propanetank | Баллон с газом | 5 |
sewingkit | Швейный набор | 5 |
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
Примеры квестов:
- Используй 50 любых ракет:
rustExplosiveType = "ROCKET",targetValue = 50 - Используй 10 MLRS ракет:
rustExplosiveType = "MLRS_ROCKET",targetValue = 10 - Используй 20 бобовых гранат:
rustExplosiveType = "BEANCAN_GRENADE",targetValue = 20 - Используй 100 любой взрывчатки:
rustExplosiveType = "ANY",targetValue = 100
Полная таблица shortname → Category
Мета-категории:
| Category | Shortnames | Описание |
|---|---|---|
ANY | * | Любая взрывчатка |
C4 | explosive.timed | Только C4 |
SATCHEL | explosive.satchel | Только satchel |
ROCKET | ammo.rocket.basic, ammo.rocket.hv, ammo.rocket.fire, ammo.rocket.mlrs | Любая ракета |
GRENADE | grenade.f1, grenade.beancan | Любая граната |
SURVEY | surveycharge | Survey charge |
Конкретные ракеты:
| Category | Shortname | Описание |
|---|---|---|
BASIC_ROCKET | ammo.rocket.basic | Обычная ракета |
HV_ROCKET | ammo.rocket.hv | Высокоскоростная ракета |
FIRE_ROCKET | ammo.rocket.fire | Зажигательная ракета |
MLRS_ROCKET | ammo.rocket.mlrs | MLRS ракета |
Конкретные гранаты:
| Category | Shortname | Описание |
|---|---|---|
F1_GRENADE | grenade.f1 | F1 граната |
BEANCAN_GRENADE | grenade.beancan | Бобовая граната |
Дополнительная взрывчатка:
| Category | Shortname | Описание |
|---|---|---|
HE_GRENADE_LAUNCHER | ammo.grenadelauncher.he | Граната из гранатомёта |
EXPLOSIVE_AMMO | ammo.rifle.explosive | Взрывные патроны |
Старые квесты с мета-категориями (ROCKET, GRENADE) продолжают работать — они матчатся со всеми соответствующими конкретными типами. Новые квесты могут использовать гранулярные типы для точного таргетинга.
Все сравнения shortname и targetType выполняются без учёта регистра: AMMO.ROCKET.MLRS === ammo.rocket.mlrs
SKILL_UPGRADED — Игрок прокачал скилл в SkillTree
- Интеграция с плагином SkillTree через hook
STOnNodeLevelUp - Отслеживает прокачку любого из 136 скиллов в 13 деревьях
- Поддержка трёх режимов: ANY (любой), TREE (дерево), SKILL (конкретный)
Payload поля:
tree— ID дерева ("Mining","Combat", etc.)skillName— buffKey скилла ("Mining_Yield","Duelist", etc.)levelCurrent— текущий уровень (после прокачки)levelMax— максимальный уровень скилла (1-5)upgradeCount— количество прокачек (всегда 1 при webhook)
Matching логика:
| Quest условие | Webhook tree | Webhook skillName | Match? |
|---|---|---|---|
ANY (tree=null) | любой | любой | ✓ |
TREE (tree="Mining") | "Mining" | любой | ✓ |
SKILL (tree="Mining", skillName="Mining_Yield") | "Mining" | "Mining_Yield" | ✓ |
Фильтр по уровню:
rustSkillTargetLevel— прогресс засчитывается только еслиlevelCurrent >= targetLevelrustSkillRequireMax— прогресс только еслиlevelCurrent == levelMax
Примеры квестов:
- Прокачай 10 любых скиллов:
tree=null, upgradeCount=10 - Прокачай 3 скилла Mining до max:
tree="Mining", requireMax=true, upgradeCount=3→ засчитываются РАЗНЫЕ скиллы - Прокачай Duelist до уровня 3:
tree="Combat", skillName="Duelist", targetLevel=3, upgradeCount=1 - Прокачай XP Catalyst до макс:
tree="Mining", skillName="XP_Catalyst", requireMax=true, upgradeCount=1
При создании SKILL_UPGRADED квестов в админке доступна визуальная fullscreen модалка выбора:
- 13 деревьев в виде горизонтальных табов
- 136 скиллов с иконками, группировка по тирам
- Для конкретного скилла (SKILL mode): кнопки выбора уровня 1-5 и "Любой" прямо в модалке
- Глобальный поиск, lazy loading изображений с fallback
Требуется установленный плагин SkillTree на Rust сервере. GoLootTracker перехватывает hook STOnNodeLevelUp, определяет совпадение с активными квестами через match criteria и добавляет increment в ProgressBuffer (V2: отправляется как QUEST_PROGRESS, не отдельным SKILL_UPGRADED webhook).
SKILL_MAXED — Игрок прокачал скилл до максимального уровня
- Срабатывает когда
levelCurrent == levelMax - Возвращает прогресс ветки:
maxedSkills/totalSkills - Используется для квестов и достижений с прогрессом (11/15 style)
Payload поля:
tree— ID дереваskillName— buffKey замакшенного скиллаmaxedSkills— сколько скиллов замакшено в этом деревеtotalSkills— всего скиллов в дереве (только enabled)activeUltimates— количество активных Ultimate скиллов (для Ultimate квестов)
Примеры:
- Квест "Замаксь 5 скиллов в Mining":
tree="Mining", targetValue=5 - Достижение "Мастер Mining (0/15)":
tree="Mining", targetValue=15→ прогресс обновляется при каждом maxe - Квест "Прокачай 3 Ultimate скилла":
rustSkillUltimateOnly=true, targetValue=3
Ultimate Quests — Специальный режим для квестов на Ultimate скиллы
Проблема: игрок может сделать respec (сбросить скиллы), прокачать тот же Ultimate снова и получить прогресс повторно.
Решение: флаг rustSkillUltimateOnly:
- Прогресс = текущее количество активных Ultimate (SET, не INCREMENT)
- При respec прогресс уменьшается вместе с количеством Ultimate
- Максимум 7 Ultimate (200 skill points / 26 per ultimate)
11 Ultimate скиллов:
Woodcutting_Ultimate,Mining_Ultimate,Combat_UltimateVehicle_Ultimate,Harvester_Ultimate,Medical_UltimateSkinning_Ultimate,Build_Craft_Ultimate,Scavengers_UltimateRaiding_Ultimate,Cooking_Ultimate
Рекомендуемые значения targetValue: 3 (лёгкий), 5 (средний), 7 (максимум)
SKILL_TREE_COMPLETED — Игрок полностью прокачал ветку скиллов
- Срабатывает когда
maxedSkills == totalSkills(все скиллы в дереве замакшены) - Один раз за сессию (дубликаты фильтруются
CompletedTreesThisSession) - Используется для бинарных достижений (0 → 1)
Payload поля:
tree— ID завершённого дереваtotalSkills— общее количество скилловmaxedSkills— = totalSkills
Пример:
- Достижение "Завершить ветку Mining":
tree="Mining"→ 0/1
Матрица типов квестов по скиллам
Quick reference для создания квестов в админке:
| Хочу квест | Режим в модалке | Event | targetProgress | Что означает |
|---|---|---|---|---|
| Прокачай любой скилл | ANY_SKILL | SKILL_UPGRADED | 1 | 1 любой скилл, любой уровень |
| Прокачай 5 любых скиллов | ANY_SKILL | SKILL_UPGRADED | 5 | 5 РАЗНЫХ скиллов |
| Прокачай 3 скилла до max | ANY_SKILL + ☑️ requireMax | SKILL_UPGRADED | 3 | 3 РАЗНЫХ скилла, каждый до max level |
| Прокачай скилл из Mining | TREE_SKILL (ветка) | SKILL_UPGRADED | 1 | 1 любой скилл в выбранной ветке |
| Прокачай 3 скилла в Combat | TREE_SKILL (ветка) | SKILL_UPGRADED | 3 | 3 РАЗНЫХ скилла в ветке |
| Прокачай Mining_Yield до ур. 3 | TREE_SKILL + скилл + уровень | SKILL_UPGRADED | 1 | Конкретный скилл до уровня 3 |
| Замаксь 5 скиллов в Mining | MAX_TREE | SKILL_MAXED | 5 | 5 из 9 скиллов Mining замакшены |
| Замаксь всю ветку Combat | MAX_TREE | SKILL_MAXED | 14 (auto) | Все 14 скиллов Combat замакшены |
| Прокачай 3 Ultimate | ☑️ Только Ultimate | SKILL_MAXED | 3 | 3 из 11 Ultimate (SET прогресс) |
| Прокачай 7 Ultimate | ☑️ Только Ultimate | SKILL_MAXED | 7 | 7 из 11 Ultimate (максимум) |
- SKILL_UPGRADED — инкрементальный: каждая подходящая прокачка увеличивает progress на 1
- SKILL_MAXED — абсолютный: progress = сколько скиллов УЖЕ замакшено в ветке
Пример разницы:
SKILL_UPGRADEDсtargetProgress=3: игрок прокачал скилл A→2, B→1, C→3 = progress 3/3 ✓SKILL_MAXEDсtargetProgress=3: игрок замаксил A, B, C = progress 3/3 ✓ (проверяется текущее состояние)
Показывается только для SKILL_UPGRADED без конкретного скилла.
Когда выбран конкретный скилл — целевой уровень определяет всё (чекбокс скрыт).
FISH_CAUGHT — Игрок поймал рыбу
- Отслеживается через Container Delta в SurvivalFishTrap
- Матчинг квестов с
rustEventType === 'FISH' - Поддержка:
ANY(любая рыба) или конкретный shortname (точное совпадение, case-insensitive) - В V2: backend раскрывает условие в
fish[]array при CONNECTED, плагин делает Contains matching - Атомарный increment по количеству пойманной рыбы
Примеры квестов:
- Поймай 50 любой рыбы:
rustFishType = null(ANY),targetValue = 50 - Поймай 10 форели:
rustFishType = "fish.troutsmall",targetValue = 10
TEA_BREWED — Игрок сварил чай на MixingTable
- Матчинг квестов с
rustEventType === 'TEA_BREWED' - Поддержка:
ANY, точный shortname, prefix matching ("healingtea"→ все healingtea.* варианты), список через запятую - В V2: backend раскрывает
teas[]array черезexpandTeaTarget()при CONNECTED (prefix разворачивается в конкретные shortnames), плагин делает Contains matching - Атомарный increment по количеству сваренного чая
Примеры квестов:
- Свари 20 любого чая:
rustTeaType = "ANY",targetValue = 20 - Свари 5 healing tea (любого уровня):
rustTeaType = "healingtea",targetValue = 5(prefix match) - Свари 10 конкретного чая:
rustTeaType = "healingtea.advanced",targetValue = 10
FARMING_HARVEST — Игрок собрал урожай
- Матчинг квестов с
rustEventType === 'FARMING_HARVEST' - Поддержка:
ANY, точный shortname, список через запятую, специальная категорияBERRY(матчит все*.berry) - В V2: backend раскрывает
harvests[]array при CONNECTED, плагин делает Contains matching - Атомарный increment по количеству собранного урожая
Примеры квестов:
- Собери 100 любого урожая:
rustHarvestType = "ANY",targetValue = 100 - Собери 50 ягод (любого типа):
rustHarvestType = "BERRY",targetValue = 50 - Собери 30 тыкв:
rustHarvestType = "pumpkin",targetValue = 30
PIE_COOKED — Игрок испёк пирог в печи
- Матчинг квестов с
rustEventType === 'PIE_COOKED' - Поддержка:
ANY, точный shortname, список через запятую - В V2: backend раскрывает
pies[]array при CONNECTED, плагин делает Contains matching - Атомарный increment по количеству испечённых пирогов
Примеры квестов:
- Испеки 10 любых пирогов:
rustPieType = "ANY",targetValue = 10 - Испеки 5 конкретных пирогов:
rustPieType = "pie.pumpkin",targetValue = 5
MISSION_COMPLETED — Игрок завершил NPC миссию
- Oxide hook:
OnMissionSucceeded(BaseMission, MissionInstance, BasePlayer)— observer-only, не блокирует - Матчинг квестов с
rustEventType === 'MISSION_COMPLETED' - Поддержка:
ANY, точный shortname, список через запятую (case-insensitive) - В V2: backend раскрывает
missions[]array при CONNECTED, плагин делает Contains matching - Атомарный increment по количеству завершённых миссий
- Редкое событие — 1 миссия = немедленный QUEST_PROGRESS flush (не ждёт 60с таймера)
17 миссий в Rust:
- Охота:
boar_hunt,deer_hunt,wildlife_cull,shark_hunt - Рыбалка:
go_fish,tackle_the_day - Сбор:
collect_vood,oiled_up - Поиск:
lost_bottles,vagabond_treasure - Exploration:
an_important_broadcast,outpost_validation - Deep Sea:
beep_in_the_deep,underwater_bounty - Доставка:
keeping_afloat - PvE/PvP:
oil_rig_raid,gone_killing
Примеры квестов:
- Выполни любую миссию:
missionShortname = "ANY",missionCount = 1 - Выполни 3 любых миссии:
missionShortname = "ANY",missionCount = 3 - Выполни «Охоту на кабанов»:
missionShortname = "boar_hunt",missionCount = 1 - Выполни 3 охотничьих миссии:
missionShortname = "boar_hunt,deer_hunt,wildlife_cull,shark_hunt",missionCount = 3
QUEST_PROGRESS — Мега-batch периодическая синхронизация (V2 Protocol, каждые 60 сек)
Заменяет все type-specific периодические таймеры одним запросом на игрока. Плагин делает matching локально и шлёт готовые пары {userQuestId, increment, actions?}.
- Плагин накапливает прогресс в
ProgressBuffer(Dictionary<string, QuestProgressEntry>) по ключуuserQuestId - Каждый
QuestProgressEntryсодержит increment + агрегированный список actions с контекстом - При flush — отправляет один
QUEST_PROGRESSwebhook с массивомupdates[] - Backend применяет increments атомарно без JOIN — только берёт метаданные из pre-snapshot
completedQuestsиactiveQuestsвозвращаются в ответеminutesPlayedвключается в QUEST_PROGRESS для обновления TIME прогресса
Отдельный SendTimeUpdates таймер (120s) сохраняется для игроков с только TIME квестами (нет ресурсной активности → ProgressBuffer пуст → QUEST_PROGRESS не срабатывает).
В V1 плагин шлёл тип события + данные (resource="wood", amount=150), backend делал matching по условиям каждого квеста. В V2 плагин делает matching локально при PLAYER_CONNECTED (получает match criteria), а при QUEST_PROGRESS шлёт только userQuestId + increment — backend просто применяет.
Payload format (V2):
{
"eventType": "QUEST_PROGRESS",
"data": {
"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 вместо INCREMENTactions— опциональный массив с контекстом действий (см. Action Context ниже)
Результат оптимизации (500 активных игроков):
| Метрика | V1 (type-specific handlers) | V2 (QUEST_PROGRESS) |
|---|---|---|
| Handler'ов на backend | 16 | 1 |
| DB запросов на webhook | 2N sequential | N+3 parallel |
| Webhooks/мин | ~350 | ~350 |
| Matching location | Backend | Plugin |
Action Context
Каждый update в QUEST_PROGRESS может содержать опциональный массив actions — контекст действий игрока, которые привели к increment. Используется для аудита и отладки в Admin Panel.
Short Keys (минимизация payload size):
| Key | Полное название | Описание | Примеры значений |
|---|---|---|---|
t | type | Тип игрового события | GATHER, CRAFT, KILL_ANIMAL, KILL_SCIENTIST, LOOT_CONTAINER, LOOT_ITEM, RECYCLE, EXPLOSIVE, FISH, TEA, HARVEST, PIE, MISSION, COMMAND, SKILL_UPGRADED, SKILL_MAXED |
d | detail | Что именно (primary subject) | "wood", "rifle.ak", "bear", "OIL_RIG", "crate_elite", "metal.ore" |
x | extra | Контекст/инструмент (опциональный) | "stone.pickaxe" (tool), "rifle.ak" (weapon), "SAFE_ZONE" (recycler type), null |
n | count | Агрегированное количество | Число (суммировано за 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 handler не изменён для работы с actions — массив автоматически сохраняется в payload JSON через webhook log. Это позволяет просматривать действия в 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) |
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 привязан, сессия создаётся без переподключения.
При вызове clearSteamVerification (DELETE /api/users/steam/trade-url) backend выполняет полную "церемонию отвязки":
- Очищает
steamId,steamTradeUrl, флаги верификации - Закрывает активные
RustPlayerSessionдля этого steamId (в той же транзакции) - Инвалидирует кэш
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
Плагин может отправлять параллельные webhook-и (несколько событий одновременно). Атомарный increment предотвращает потерю данных при concurrent writes.
4. Season Status Check
Прогресс квестов обновляется только при Season.status = ACTIVE:
Webhook → isSeasonActive()?
├─ Да → Обновить прогресс
└─ Нет (COUNTDOWN/COMPLETED) → Вернуть пустой список квестов
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 sequential | N+3 parallel | -значительно |
V2-специфичные оптимизации:
- Plugin-side matching — backend не делает matching по условиям квестов, только применяет increments
- expand-match-criteria.ts — раскрытие категорий в конкретные shortnames выполняется один раз при
PLAYER_CONNECTED, а не на каждый webhook - Pre-snapshot + parallel updates —
handleQuestProgressснимает 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_seconds | Histogram | Время обработки batch (buckets: 1s–60s) |
webhook_batch_players | Histogram | Количество игроков в batch |
webhook_batch_errors_total | Counter | Полностью провалившиеся batch-и |
Grafana alert rules (provisioning в monitoring/grafana/provisioning/alerting/):
| Алерт | Порог | Sustained | Severity | Что значит |
|---|---|---|---|---|
| Batch Duration Warning | p95 > 20s | 5 min | warning | Приближаемся к лимиту (batch guard 30s) |
| Batch Duration Critical | p95 > 30s | 3 min | critical | Batch guard сбрасывает _isBatchSending, возможна потеря данных |
| Batch Errors | любой полный fail | instant | critical | Все игроки в 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.
Функция 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 | Поддерживаемые значения |
|---|---|
| GATHER | ANY, ORE, конкретный shortname. Способ добычи: DISPENSER, COLLECTIBLE, QUARRY, EXCAVATOR, ANY_QUARRY |
| LOOT_CONTAINER | ANY, 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 |
| Weapon | ANY, MELEE, RANGED, EXPLOSIVE, конкретный shortname |
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| Webhook | rustWebhook (400/min per-server) | Plugin API Key | RustWebhookPayloadSchema |
| Batch tasks | rustWebhook (400/min per-server) | Plugin API Key | BatchActiveQuestsRequestSchema |
| Get tasks | rustWebhook (400/min per-server) | Plugin API Key | SteamId param |
| Health check | rustWebhook (400/min per-server) | Plugin API Key | — |
| List servers | general | Admin JWT | — |
| Create server | mutations | Admin JWT | CreateRustServerSchema |
| Update server | mutations | Admin JWT | UpdateRustServerSchema |
| Delete server | mutations | Admin JWT | DeleteRustServerSchema |
| Reveal credentials | mutations | Admin JWT + Password + 2FA | RevealCredentialsSchema |
| Get logs | general | Admin JWT | GetServerLogsSchema |
Плагин аутентифицируется через 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 Key | 401 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 с непустой DLQ | Warning в логах: Plugin unloading with N unsent webhooks in DLQ! — данные теряются |
| Rate limit 429 (Rust endpoint) | Плагин получает 429 с retryAfter, может retry. Лимит 400 req/min per-server |
| Stale userQuestId в QUEST_PROGRESS | Backend проверяет pre-snapshot: если userQuestId не в active snapshot — update пропускается (квест завершён или отозван) |
useAbsolute: true (Ultimate квесты) | Плагин шлёт absoluteValue вместо increment. Backend применяет SET вместо INCREMENT (защита от respec-атаки) |
| Completed quest stale data | handleQuestProgress берёт completed quests из pre-snapshot (до инкремента в БД). mapCompletedQuests enforces current = Math.max(current, target) |
| ProgressBuffer flush vs InFlight | ProgressBuffer использует свой InFlight ключ. Если QUEST_PROGRESS в полёте — новые события копятся в буфере до следующего тика |
| TIME data в QUEST_PROGRESS | minutesPlayed в 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 chunking | 50 players per chunk. Promise.allSettled — partial failure не блокирует остальных |
| Plugin unload с disconnected buffers | SaveToPending() сохраняет pending minutes для каждого disconnected buffer |
| Квест взят пока игрок онлайн | При следующем batch response плагин получает обновлённый activeQuests с новым квестом и его match criteria |
Backend Error Codes (для API/тестов)
| Код | HTTP | Контекст |
|---|---|---|
Missing or invalid Authorization header | 401 | Нет Bearer token |
Invalid API key | 401 | Неверный API key |
Server is deactivated | 403 | Сервер выключен |
Server not found | 404 | Сервер не существует |
Cannot delete server with related data | 409 | Есть связанные логи/сессии/квесты |
Troubleshooting
"Плагин показывает квесты, но backend возвращает пустой список"
Возможные причины:
-
Сезон неактивен - проверить
SELECT * FROM seasons WHERE "isActive" = true- Backend возвращает
[]если нет активного сезона - Плагин кэширует старый список квестов локально
- Backend возвращает
-
Quest не привязан к серверу - проверить
Quest.rustServerIdSELECT id, title, "rustServerId" FROM quests WHERE title = 'имя квеста'; -
UserQuest статус не IN_PROGRESS - проверить статус
SELECT status FROM user_quests WHERE "questId" = 'xxx' AND "userId" = 'yyy'; -
Логи плагина из разных файлов - плагин создаёт новый файл лога в 00:00
- Старые логи могут показывать состояние до активации сезона/взятия квеста
Ищите в логах плагина:
Webhook QUEST_PROGRESS success: {"activeQuests": [...]}- что вернул backendSyncing 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 | Что значит | Что делать |
|---|---|---|---|---|
| 1 | Batch Duration Warning (p95 > 20s, 5 min) | warning | Batch приближается к лимиту. Окно 60s, guard 30s — ты в жёлтой зоне | /diagnostics "batch processing slow" — вероятно больше игроков чем обычно или DB медленная. Ничего не чини срочно |
| 2 | Batch Duration Critical (p95 > 30s, 3 min) | critical | Batch guard сбрасывает _isBatchSending. Возможны дубликаты или пропуски прогресса | /diagnostics "webhook batch critical duration" — если причина рост игроков → нужна оптимизация (но не на горящей системе). Если DB тормозит без роста → проверить другие эндпоинты |
| 3 | Batch Errors (полный fail) | critical | ВСЕ игроки в batch failed (100%) — почти наверняка DB connectivity | /diagnostics --errors + проверить curl https://api/health. Данные в DLQ плагина, ретрай каждые 30s, TTL 10 мин |
| 4 | High Error Rate (>5% 5xx, 5 min) | critical | Больше 5% HTTP запросов возвращают 500. Все эндпоинты, не только webhook | /diagnostics --errors — Claude покажет какие эндпоинты падают. Если всё — серверная проблема (DB/memory/crash) |
| 5 | High Response Time (p95 > 1s, 5 min) | warning | Медленные ответы по всему API. Пользователи TMA ждут | /diagnostics "slow responses" — часто тяжёлый запрос блокирует connection pool |
Запас прочности и пороги
| Метрика | Норма (500 игроков) | Warning | Critical | Физический лимит |
|---|---|---|---|---|
| Batch duration | ~7s | >20s | >30s | 60s (интервал таймера) |
| Batch guard timeout | — | — | 30s | Guard сбрасывает флаг |
| DLQ TTL | — | — | — | 30 мин (потеря данных) |
| Circuit breaker | closed | — | open (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 добавлено для различения:
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 требует повторной верификации:
- Проверка пароля админа
- Проверка 2FA кода (если включена)
- 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, но требует полных словарей для классификации.
Подход взят из 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, чтобы игрок видел "честный" прогресс на сервере.
Решение: Отклонено. Оставить текущую архитектуру с батчами.
Почему не нужно:
-
Проблема не существует —
/golootуже показывает реальный прогресс:// Прогресс = backend + локальный буфер
totalMinutes = quest.CurrentMinutes + accumulatedMinutes; -
Данные не теряются — защита на всех уровнях:
OnPlayerDisconnected— flush при выходеPendingSaveInterval(120 сек) — сохранение на дискUnload()— flush при выгрузке плагина
-
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 (мета).
Почему так:
- Переиспользование инфраструктуры — те же поля в
rustParams(killScientistType,killScientistCount,killWeaponType) - Упрощение плагина — один webhook event
SCIENTIST_KILLED, тот же буферKillScientistBuffer - 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 перенесён на сторону плагина.
- При
PLAYER_CONNECTEDbackend раскрывает условия квестов в конкретные значения (expandMatchCriteria) и возвращаетmatch: QuestMatchCriteria - Плагин строит
HashSet<string>из полученных значений —O(1)Contains check - При игровом событии плагин делает Contains → если совпадение — добавляет
{userQuestId, increment}вProgressBuffer - Периодически (60с) шлёт
QUEST_PROGRESSс готовыми increments. Backend просто применяет — без matching
Ключевые решения:
expandMatchCriteriaвыполняется один раз при CONNECTED, не на каждый webhook["*"]= wildcard (любое значение),undefined= критерий не проверятьabsoluteValueдля Ultimate квестов — SET вместо INCREMENT (защита от respec)- Один лог на QUEST_PROGRESS (не N логов) — экономия DB writes
- Один
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 — два механизма вместо одного
Ключевые решения:
| Решение | Выбор | Почему |
|---|---|---|
| Logging | 1 batch summary + N per-player records | Admin DX preserved |
| Dedup | PROCESSING→PROCESSED status + P2002 | Crash recovery без потери данных |
| Chunking | 50 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 черезmatchcriteria - Плагин знает какой квест на что реагирует (из
typeполя) - Backend хранит только
targetProgressиcurrentProgress— универсальные поля
Ключевые решения:
applyProgressIncrementпринимаетquestMeta(передаётся из pre-snapshot) — без JOIN к таблице квестовupdates— массив, все применяются черезPromise.allSettled(параллельно)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.
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 рейтами)
Вопрос: считать "честное" базовое значение или финальное с модификаторами?
Решение: Засчитывать финальное значение (все модификаторы учитываются).
Почему так:
-
Архитектура 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
- Хуки (
-
Справедливость:
- Игрок использовал ресурс (чай) → заслужил бонус к прогрессу
- Игрок выбрал сервер с высокими рейтами → это часть игрового опыта
-
Простота:
- Не требует дополнительной конфигурации
- Не требует знания о плагинах рейтов на сервере
- Квесты работают одинаково на всех серверах
Альтернативы (отклонены):
| Вариант | Почему отклонён |
|---|---|
Серверный нормализатор (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 напрямую:
- Извлекает все ресурсы из
dispenser.containedItems - Применяет множители (yield, night bonus, luck)
- Выдаёт предметы через
GiveItem() - Уничтожает ноду (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 изменит или уберёт хук OnInstantGatherTriggered — instant mining перестанет засчитываться. Мониторить changelog SkillTree при обновлениях.
Как работает DLC маппинг?
Проблема: DLC скины имеют уникальные shortnames, отличающиеся от базового предмета:
rocket.launcher— базовый RPGrocket.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).
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 маппинг |
|---|---|---|
| CRAFT | GetBaseShortname(item.info) | ✅ |
| RECYCLE | GetBaseShortname(item.info) | ✅ |
| KILL_ANIMAL/SCIENTIST (weapon) | GetWeaponShortname() → GetBaseShortname() | ✅ |
| LOOT_ITEM | GetBaseShortname(itemDef) | ✅ (DLC не дропается из контейнеров, но защита есть) |
| GATHER (resources/tools) | Не нужен | DLC версий ресурсов/инструментов не существует |
| FISH | Не нужен | DLC версий рыбы не существует |
Почему гибридный подход:
isRedirectOf— основной источник, автоматически работает для ~95% DLCDlcFallbackMapping— резервный вариант для DLC, где Facepunch забыл заполнить поле- Fallback применяется ко всем событиям через единую точку входа
GetBaseShortname()
Последствия:
- Автоматическая поддержка большинства DLC через
isRedirectOf - Ручной fallback для проблемных 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) | 40 | 12 | 480 |
| Обычная игра (строительство, редкий фарм) | 40 | 30 | 1,200 |
| Активный гринд (квесты, фарм, крафт) | 20 | 70 | 1,400 |
| TOTAL | 100 | — | ~3,080/час |
В день: ~74k записей (~1.3 GB с индексами и payload)
Пиковая нагрузка (все игроки активны): ~7,000 webhook/час (~168k/день)
Без cleanup БД вырастет до десятков GB за месяц.
Решение: Дифференцированные retention periods в зависимости от статуса лога:
| Статус | Retention | Причина |
|---|---|---|
PROCESSED | 14 дней | Успешные события — для аудита и дебага |
FAILED | 30 дней | Ошибки требуют длительного исследования |
PENDING | 1 день | Зависшие события — аномалия требующая внимания |
Альтернативы (отклонены):
- Единый retention 14 дней для всех — теряем FAILED логи для debug
- Партиционирование таблицы — переусложнение, Prisma не поддерживает нативно
- Архивация в S3 — дополнительная инфраструктура, избыточно для логов
- Без cleanup — БД вырастет до критического размера
Последствия:
- Стабилизация БД на ~15 GB (14 дней PROCESSED + 30 дней FAILED)
- FAILED логи доступны месяц для анализа patterns
- Warning при >100 PENDING старше 1 дня — индикатор проблем с обработкой
Если 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 Timer | 30 сек | Интервал проверки очереди |
TTL | 10 мин | Максимальный возраст элемента в 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 в логах
Если backend лежит > 10 минут — это инцидент, требующий ручного вмешательства. DLQ элементы старше 10 мин удаляются с warning логом. Дубли при повторной отправке блокируются DB constraint (P2002) по deduplicationKey.
Почему database-level дедупликация вместо SELECT-then-INSERT?
Проблема: Старый подход (SELECT проверка → INSERT) создавал несколько проблем:
- TOCTOU race condition — два одинаковых webhook-а проходят проверку одновременно до того, как любой из них создаст запись
- JSON path сканирование без индекса — медленные запросы по
payload->>'timestamp'по всей таблице - Ручной маппинг
getContentKeyField()— требует обновления при каждом новом типе события - 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 v2 (с
webhookId): ключ =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 на webhook | 2 | 1 (при дубле: 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 без изменений.
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 невозможен |
| Старые записи без deduplicationKey | NULL — не попадают под 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, используется в WHERErustEventType RustQuestEventType?— discriminator, используется в WHERE и raw SQL (titles)
Что осталось в UserQuest: 15 progress полей (rustMinutesPlayed, rustCommandCount, rustLootCount и др.) — атомарный { increment: x } в Prisma не работает с JSON.
Naming convention: Внутри rustParams поля без rust prefix: rustFishType → rustParams.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() ДО SendWebhook | SendWebhook сериализует 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 очищает InFlight | Disconnect = 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
Раньше плагин отправлял фейковый HEARTBEAT на POST /rust/webhook для проверки связи, что возвращало 400 (неизвестный event type) и создавало шум в логах. GET /rust/health --- lightweight endpoint без записи в БД, только проверка auth + статус сервера.
Webhook Flow
В 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
| Компонент | Путь | Описание |
|---|---|---|
| WebhookController | backend/src/domains/rust-integration/controllers/webhook.controller.ts | HTTP handlers |
| WebhookService | backend/src/domains/rust-integration/services/webhook.service.ts | Роутинг per-player событий (CONNECTED, DISCONNECTED) |
| WebhookBatchService | backend/src/domains/rust-integration/services/webhook-batch.service.ts | Server-side batching: дедупликация, chunk processing (50), per-player logs, crash recovery |
| WebhookHandlers | backend/src/domains/rust-integration/services/webhook-handlers.service.ts | 3 обработчика: CONNECTED, DISCONNECTED, QUEST_PROGRESS (переиспользуется batch service) |
| RustQuestProgressService | backend/src/domains/rust-integration/services/rust-quest-progress.service.ts | Обновление прогресса квестов: applyProgressIncrement, updateTimeProgress, getActiveRustQuests |
| PlayerSessionService | backend/src/domains/rust-integration/services/player-session.service.ts | Управление сессиями |
| SteamLinkingService | backend/src/domains/rust-integration/services/steam-linking.service.ts | Steam ↔ User mapping (Redis cache, TTL 5 min) |
| WebhookCleanupJob | backend/src/domains/rust-integration/jobs/webhook-cleanup.job.ts | Автоматическая очистка старых логов (daily at 03:00 UTC) |
| Plugin Auth Middleware | backend/src/domains/rust-integration/middleware/plugin-auth.middleware.ts | API Key verification |
| Webhook Routes | backend/src/domains/rust-integration/routes/webhook.routes.ts | Plugin API |
| Admin Routes | backend/src/domains/rust-integration/routes/admin-rust.routes.ts | Server management |
| Admin UI: Server Detail | admin/src/pages/RustServerDetail.tsx | Plugin config (API Key reveal с auto-hide), webhook logs с clickable фильтрами и payload summary |
| QuestMapper | backend/src/domains/rust-integration/mappers/quest-mapper.ts | V2: возвращает { userQuestId, match: QuestMatchCriteria }. mapCompletedQuests enforces current >= target через Math.max |
| ExpandMatchCriteria | backend/src/domains/rust-integration/utils/expand-match-criteria.ts | Раскрытие категорий квестов в конкретные значения для plugin-side matching. Вызывается при PLAYER_CONNECTED |
| SkillTree Constants | backend/src/domains/rust-integration/constants/skill-tree.ts | Константы SkillTree деревьев и скиллов |
| Webhook Schemas | backend/src/domains/rust-integration/schemas/webhook.schemas.ts | Валидация webhook payload (V2: 3 event types) |
| Admin Schemas | backend/src/domains/rust-integration/schemas/admin-rust.schemas.ts, admin-rust-server.schemas.ts | Валидация admin API |
| Webhook Swagger Schemas | backend/src/domains/rust-integration/schemas/webhook-swagger.schemas.ts | OpenAPI документация |
| Types | backend/src/domains/rust-integration/types/rust.types.ts | V2: 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 | Получен, ожидает обработки |
PROCESSING | Batch в процессе обработки (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
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
- Plugin API
- Admin: Servers
- Admin: Operations
| Метод | Эндпоинт | Описание | Auth |
|---|---|---|---|
| GET | /rust/health | Health check: проверка auth + активности сервера. Без записи в БД | Bearer API Key |
| POST | /rust/webhook | Обработка события от плагина (PLAYER_CONNECTED, PLAYER_DISCONNECTED, QUEST_PROGRESS) | Bearer API Key |
| POST | /rust/webhook/batch | Батчевый QUEST_PROGRESS — все игроки одним запросом, ответ содержит activeQuests | Bearer API Key |
| GET | /rust/tasks/:steamId | Активные квесты одного игрока (используется /goloot chat command) | Bearer API Key |
Плагин вызывает 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 (сервер деактивирован)
Плагин отправляет один 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:
- Дедупликация по
webhookId(INSERT с status=PROCESSING, P2002 catch) - Bulk resolve users: один
findManyдля всех steamIds - Создание per-player logs через
createMany(eventType=QUEST_PROGRESS, parentBatchId=batchLogId) - Chunk processing: 50 players per chunk,
Promise.allSettled→handleQuestProgress()per-player 4.5. Sync questless players: bulk fetchactiveQuestsдляsyncPlayers(без логов, без progress processing) - Финализация: batch log → PROCESSED, per-player logs обновлены
PLAYER_CONNECTED / PLAYER_DISCONNECTED остаются per-player через POST /rust/webhook (редкие lifecycle events).
Плагин делает 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 вместо INCREMENTactions— опциональный audit trail, автосохраняется в webhook log payload
{
"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.
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/rust-servers | Список серверов | -> |
| GET | /admin/rust-servers/:id | Детали сервера | -> |
| POST | /admin/rust-servers | Создать сервер | -> |
| PUT | /admin/rust-servers/:id | Обновить сервер | -> |
| DELETE | /admin/rust-servers/:id | Удалить сервер | -> |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| POST | /admin/rust-servers/:id/regenerate-key | Новый API Key | -> |
| POST | /admin/rust-servers/:id/reveal-credentials | Показать credentials (Password + 2FA) | -> |
| GET | /admin/rust-servers/:id/logs | Логи webhook-ов | -> |
API Key отображается на странице деталей сервера (Plugin Configuration):
- Eye/EyeOff toggle для показа/скрытия
- Кнопка копирования в буфер обмена
- Автоматическое скрытие через 60 секунд с обратным отсчётом
- Требуется верификация пароля (+ 2FA) через ServerCredentialsModal
Страница логов (RustServerDetail.tsx) поддерживает:
- Clickable фильтры — клик на eventType/steamId в таблице применяет фильтр
- Payload summary — ключевые данные из payload отображаются без раскрытия (например,
Ресурс: wood,Количество: 150) - User link — клик на имя пользователя открывает UserDetailsModal с tooltip Telegram ID
- Refresh — кнопка обновления логов (RefreshCw) с сохранением текущих фильтров
Plugin Configuration
Log Levels
Плагин GoLootTracker поддерживает настраиваемые уровни логирования через конфиг и консольную команду.
Команда: goloot.loglevel <0-2>
| Level | Название | Описание | Пример вывода |
|---|---|---|---|
0 | OFF | Логирование отключено | — |
1 | INFO | Бизнес-события (default) | [GoLootTracker] [QUARRY] Player gathered 5x hq.metal.ore |
2 | DEBUG | INFO + диагностика | [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 статус изменения
- Синхронизация квестов
Включай Level 2 когда:
- Квест не засчитывается и нужно понять почему
- Проверить что webhook доходит до backend
- Диагностика проблем с сессиями
7. Related
- Quests — RUST квесты как категория квестов
- Steam Verification — верификация Steam аккаунта для Rust квестов
- Seasons — прогресс квестов только при ACTIVE сезоне