Skip to content

Comments

Hotfix 22.01.2026#1430

Merged
Rxup merged 7 commits intomasterfrom
hotfix-220126
Jan 22, 2026
Merged

Hotfix 22.01.2026#1430
Rxup merged 7 commits intomasterfrom
hotfix-220126

Conversation

@Rxup
Copy link
Owner

@Rxup Rxup commented Jan 22, 2026

@Roudenn

Summary by CodeRabbit

  • Новые функции

    • НПС: пред-условие «лежит/встать» и быстрый оператор «встать» в боевых задачах.
    • Опция GPT‑диалогов доступна для отдельных НПС (включается в прототипах), с историей и функциями вызова.
  • Исправления ошибок

    • Перед зомбификацией цель корректно переводится в стоячее состояние; улучшено восстановление тела.
    • Унификация всплывашек и звуков при «ударе в спину» для предсказуемого поведения.
  • Рефакторинг

    • Упрощены предусловия и запросы для НПС; оптимизированы генерация и встраивание руин.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 22, 2026

Walkthrough

Добавлены HTN-предусловие LaydownPrecondition и оператор StandOperator; StandingStateSystem интегрирован в зомбификацию и восстановление тел; в LavalandPlanetSystem добавлен/перенес сложный механизм загрузки и слияния руин; внедрена масштабная GPT-логика в NPCConversationSystem; обновлены прототипы NPC; удалены/упрощены некоторые зависимости.

Changes

Cohort / File(s) Резюме
HTN: предусловие и оператор
Content.Server/Backmen/NPC/HTN/LaydownPrecondition.cs,
Content.Server/Backmen/NPC/HTN/PrimitiveTasks/Operators/Combat/StandOperator.cs
Добавлен LaydownPrecondition (поле IsDown, инициализация StandingStateSystem, реализация IsMet) и StandOperator (инициализация StandingStateSystem, Startup вызывает Stand(owner), Update сразу возвращает Finished).
NPC зомбификация / восстановление тела
Content.Server/Backmen/Shipwrecked/NPCZombieSystem.cs,
Content.Server/Zombies/ZombieSystem.Transform.cs
Интеграция StandingStateSystem (вызов Stand перед зомбификацией) и добавлен вызов _bodySystem.ForceRestoreBody(target, true) после восстановления сознания; мелкие правки компонентов при зомбификации.
Lavaland — загрузка и слияние руин
Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs,
Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.cs
Добавлен LoadGridRuin с кэшем границ, проверкой конфликтов, загрузкой на dummy-карту, реперинтингом и слиянием тайлов/фикстур; класс сделан partial; удалена зависимость на FixtureSystem; обновлены вызовы.
NPC разговоры и GPT
Content.Server/Backmen/NPC/Components/NPCConversationComponent.cs,
Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs
В компонент добавлены UseGpt, GptSystemPrompt, GptProcessing, автопауза полей; в системе добавлена большая GPT-подсистема (история, запросы, функции), новые типы NPCGptHistory и EventGptFunctionCallNPC, множество методов сменили сигнатуры на Entity<NPCConversationComponent?>.
Прототипы NPC
Resources/Prototypes/NPCs/Combat/melee.yml,
Resources/Prototypes/_Backmen/Entities/Mobs/NPCs/glimmer_monsters.yml,
Resources/Prototypes/_Backmen/Entities/Mobs/NPCs/special.yml,
Resources/Prototypes/_Backmen/Entities/Structures/Research/sophicscribe.yml
В melee.yml добавлена ветка с LaydownPrecondition isDown: true и StandOperator; в glimmer-моделях активированы IdleCompound ветви и упрощены preconditions; у некоторых NPC выставлен useGpt: true.
Удаление/упрощение зависимостей и мелкие правки
Content.Server/Backmen/Vampiric/BloodSuckerSystem.cs,
Content.Server/Weapons/DamageMarkerSystem.cs,
RobustToolbox submodule
Удалены поля-зависимости (NPCRetaliationSystem, SharedStunSystem), небольшие рефакторы ветвей и обновлён коммит подмодуля.
Агрессоры / фракции
Content.Shared/_Lavaland/Aggression/SharedAggressorsSystem.cs
Добавлена интеграция с NpcFactionSystem, введён EntityQuery<ActorComponent>, изменены публичные сигнатуры и логика регистрации/очистки агрессоров.
Backstab / эффекты
Content.Shared/_White/BackStab/BackStabSystem.cs
Сделан приватным BackstabSound, добавлен кэш типа урона Slash и _mobStateQuery, дефолт alwaysBackstabLayingfalse, BackstabEffects теперь принимает user + target и использует предсказанные вызовы UI/звука.

Sequence Diagram(s)

sequenceDiagram
  participant Planner as HTN Planner
  participant BB as NPCBlackboard
  participant Owner as NPC Entity
  participant StandSys as StandingStateSystem
  Planner->>BB: Evaluate LaydownPrecondition(IsDown)
  BB-->>Planner: return owner Entity
  alt Owner needs to change state
    Planner->>StandSys: Startup(owner) => Stand(owner)
    StandSys-->>Owner: perform Stand action
    StandSys-->>Planner: acknowledge
    Planner->>Planner: Update -> return Finished
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Я кролик в коде — тихо прыгаю в ряд,
Предусловие легло, встало, как подряд.
Руины слились, диалоги шепчут в сеть,
Я восклицаю: «Стой!» — NPC встают в рассвет.

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.66% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive Название 'Hotfix 22.01.2026' является чрезмерно общим и неинформативным. Оно не описывает основные изменения в коммите. Используйте более описательное название, которое кратко резюмирует основные изменения (например, 'Add NPC laydown system and GPT conversation support' или аналогичное, отражающее суть модификаций).
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch hotfix-220126

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Content.Server/Backmen/Shipwrecked/NPCZombieSystem.cs (1)

67-78: Дублирующееся присваивание HealingOnBite.

z.HealingOnBite = new() устанавливается дважды: на строках 67 и 77. Первое присваивание избыточно.

💚 Удаление дубликата
         if (!ev.IsBoss)
         {
-            z.HealingOnBite = new();
-
             DamageSpecifier dspec = new()
             {
                 DamageDict = new()
Resources/Prototypes/_Backmen/Entities/Mobs/NPCs/glimmer_monsters.yml (1)

221-230: Риск слишком широкого выбора цели для UtilityOperator.

После удаления ограничений по LOS/дистанции NearbyGunWispTargets теперь может выбирать цели за стенами или на другом конце карты. Это ведёт к лишнему pathfinding и странному поведению (бесконечное преследование). Если это не намеренно, стоит вернуть хотя бы TargetDistanceCon и TargetInLOSOrCurrentCon либо ограничить радиус запроса.

🤖 Fix all issues with AI agents
In `@Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs`:
- Around line 89-94: When the intersection check (usedSpace.Any(used =>
used.Intersects(ruinBox))) fails you must remove the temporary grid that was
loaded onto the preloader before returning false; locate the variable that holds
the loaded grid (the temporary grid/entity placed on the preloader map when
computing bounds) and ensure you call the appropriate removal method (e.g.
_entityManager.DeleteEntity(loadedGrid) or preloader.RemoveEntity(loadedGrid))
right before the early return in the failing branch so the unused grid entity is
not left on the preloader.
- Around line 56-62: The code currently builds a list `bounds` from
`manager.Fixtures.Values` (filtering `fixture.Hard`) and then calls
`_random.Pick(bounds)` which will throw if `bounds` is empty; add a check after
computing `bounds` to handle empty lists: log an error mentioning the ruin
preloader/spawn (`spawned` and `preloader.Owner` or other contextual
identifiers), delete the temporary entity (`spawned.Value` or the same temp
entity used) and return false from the enclosing method; ensure you reference
the same variables used here (`_physics.GetRelativePhysicsTransform`,
`manager.Fixtures`, `fixture.Hard`, `_random.Pick`) so the fix is applied in the
same block.

In `@Content.Shared/_Lavaland/Aggression/SharedAggressorsSystem.cs`:
- Around line 34-38: В обработчике OnBeforeDamageChanged не отменяй урон для
сущностей без ActorComponent на событиях с null Origin — вместо присваивания
args.Cancelled = true просто прекрати обработку и выйди из метода: проверку
оставь (args.Origin == null && !_actorQuery.HasComp(ent)) но не выставляй
Cancelled; нужен лишь ранний return в методе OnBeforeDamageChanged, чтобы не
блокировать средовой урон, при этом сохранил логику работы с
BeforeDamageChangedEvent и метод/поле _actorQuery.HasComp(ent).

In `@Content.Shared/_White/BackStab/BackStabSystem.cs`:
- Around line 100-103: В вызове _audio.PlayPredicted(BackstabSound, user,
target) поменяйте местами аргументы user и target, т.е. передавайте target как
источник звука, чтобы вызов стал _audio.PlayPredicted(BackstabSound, target,
user); это исправит некорректную семантику источника звука для эффекта в
BackStabSystem, сохраняя вызов PopupPredicted без изменений.

In `@Resources/Prototypes/NPCs/Combat/melee.yml`:
- Around line 39-45: В файле YAML есть несогласованность отступов: блоки списка
под preconditions и tasks используют 2 пробела, тогда как остальные элементы
(строки 16–37) — 4 пробела; приведите отступы к единому стилю (4 пробела) для
записей - !type:LaydownPrecondition, - !type:HTNPrimitiveTask и вложенных полей
operator, shutdownState: TaskFinished и isDown: true, сохранив структуру и
вложенность.
🧹 Nitpick comments (3)
Content.Server/Backmen/NPC/HTN/PrimitiveTasks/Operators/Combat/StandOperator.cs (1)

15-20: Результат Stand игнорируется.

Метод _stand.Stand(owner) возвращает bool, указывающий на успешность операции. Если вставание не удалось (например, отменено событием StandAttemptEvent), оператор всё равно вернёт Finished. Это может быть намеренным, но стоит убедиться, что это ожидаемое поведение.

♻️ Опциональное улучшение: логирование или обработка неудачи
 public override void Startup(NPCBlackboard blackboard)
 {
     base.Startup(blackboard);
     var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
-    _stand.Stand(owner);
+    if (!_stand.Stand(owner))
+    {
+        // Логирование или установка флага неудачи при необходимости
+    }
 }
Content.Server/Backmen/NPC/HTN/LaydownPrecondition.cs (1)

24-30: Логику IsMet можно упростить.

Текущее выражение эквивалентно простому сравнению равенства.

♻️ Упрощение логики
 public override bool IsMet(NPCBlackboard blackboard)
 {
     var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
 
-    return IsDown && _stand.IsDown(owner) ||
-           !IsDown && !_stand.IsDown(owner);
+    return IsDown == _stand.IsDown(owner);
 }
Content.Shared/_Lavaland/Aggression/SharedAggressorsSystem.cs (1)

77-83: Guard для Aggro/DeAggro, чтобы избежать повторных побочных эффектов.

Сейчас AggroEntity/DeAggroEntity вызываются даже если агрессор уже есть/его нет в HashSet. Это может дублировать работу и потенциально портить состояние, если методы не идемпотентны. Лучше вызывать их только при реальном изменении множества.

♻️ Вариант правки
 public void RemoveAggressor(Entity<AggressiveComponent> ent, EntityUid aggressor)
 {
-    _npcFaction.DeAggroEntity(ent.Owner, aggressor);
-    ent.Comp.Aggressors.Remove(aggressor);
+    if (!ent.Comp.Aggressors.Remove(aggressor))
+        return;
+    _npcFaction.DeAggroEntity(ent.Owner, aggressor);
     RaiseLocalEvent(ent, new AggressorRemovedEvent(GetNetEntity(aggressor)));
 }

 public void AddAggressor(Entity<AggressiveComponent> ent, EntityUid aggressor)
 {
-    _npcFaction.AggroEntity(ent.Owner, aggressor);
-    ent.Comp.Aggressors.Add(aggressor);
+    var isNew = ent.Comp.Aggressors.Add(aggressor);
+    if (isNew)
+        _npcFaction.AggroEntity(ent.Owner, aggressor);

     var aggcomp = EnsureComp<AggressorComponent>(aggressor);
-    RaiseLocalEvent(ent, new AggressorAddedEvent(GetNetEntity(aggressor)));
+    if (isNew)
+        RaiseLocalEvent(ent, new AggressorAddedEvent(GetNetEntity(aggressor)));

     aggcomp.Aggressives.Add(ent);

Also applies to: 97-107

Comment on lines +56 to +62
// Actually calculate ruin bound
var transform = _physics.GetRelativePhysicsTransform(spawned.Value, preloader.Owner);
// holy shit
var bounds = (from fixture in manager.Fixtures.Values where fixture.Hard select fixture.Shape.ComputeAABB(transform, 0).Rounded(0)).ToList();
// Round this list of boxes up to
var calculatedBox = _random.Pick(bounds);
foreach (var bound in bounds)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "LavalandPlanetSystem.Grid.cs" 2>/dev/null | head -20

Repository: Rxup/space-station-14

Length of output: 186


🏁 Script executed:

cat -n "Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs" | sed -n '50,70p'

Repository: Rxup/space-station-14

Length of output: 1108


🏁 Script executed:

# Search for _random.Pick implementation
rg "_random\.Pick" -A 5 -B 2 --type cs

Repository: Rxup/space-station-14

Length of output: 50378


🏁 Script executed:

# Search for the Random class implementation to understand Pick behavior
rg "Pick\(" --type cs | grep -i "random\|\.pick" | head -20

Repository: Rxup/space-station-14

Length of output: 2719


🏁 Script executed:

# Search for existing empty collection guards in similar contexts
rg "\.Count\s*==\s*0" --type cs | head -20

Repository: Rxup/space-station-14

Length of output: 2349


🏁 Script executed:

# Search for Random.Pick implementation in the codebase
rg "public.*Pick\(" --type cs -A 10 | grep -A 10 "IRobustRandom\|IEnumerable"

Repository: Rxup/space-station-14

Length of output: 3951


🏁 Script executed:

# Check the Random extension methods that implement Pick
find . -name "*.cs" -type f -exec grep -l "SharedRandomExtensions\|RandomExtensions" {} \; 2>/dev/null | head -5

Repository: Rxup/space-station-14

Length of output: 327


🏁 Script executed:

# Look at the actual Random.Pick implementation
fd -t f "RandomExtensions.cs" | head -3

Repository: Rxup/space-station-14

Length of output: 120


🏁 Script executed:

cat -n "Content.Shared/Random/Helpers/SharedRandomExtensions.cs" | head -100

Repository: Rxup/space-station-14

Length of output: 3899


🏁 Script executed:

# Search for the generic Pick<T> overload that works with IList/IEnumerable
rg "public static.*Pick.*IList|public static.*Pick.*IEnumerable" --type cs -A 8 -B 2

Repository: Rxup/space-station-14

Length of output: 47


🏁 Script executed:

cat -n "Content.Shared/Random/Helpers/SharedRandomExtensions.cs" | sed -n '100,200p'

Repository: Rxup/space-station-14

Length of output: 4047


🏁 Script executed:

# Search for Pick<T> with IList or similar collections
rg "Pick.*IList|Pick.*List\<" --type cs -B 2 -A 8

Repository: Rxup/space-station-14

Length of output: 177


🏁 Script executed:

cat -n "Content.Shared/Random/Helpers/SharedRandomExtensions.cs" | tail -100

Repository: Rxup/space-station-14

Length of output: 4055


🏁 Script executed:

# Search for Pick method overloads that work with lists/collections
rg "public static.*Pick" --type cs Content.Shared/Random/Helpers/SharedRandomExtensions.cs

Repository: Rxup/space-station-14

Length of output: 1143


🏁 Script executed:

# Look in Robust.Shared for the base Pick implementation
fd -t f "RandomExtensions.cs" 2>/dev/null | xargs grep -l "Pick" 2>/dev/null | head -5

Repository: Rxup/space-station-14

Length of output: 120


🏁 Script executed:

# Search for Pick implementations in the entire codebase that work with lists
rg "Pick.*IList\|Pick.*IEnumerable\|Pick.*List" --type cs -B 2 -A 5 | head -100

Repository: Rxup/space-station-14

Length of output: 47


🏁 Script executed:

# Look for the actual generic Pick method used on bounds (which is List<Box2>)
# Search files that might have the base implementation
find . -type f -name "*.cs" | xargs grep -l "public static.*Pick.*where.*:" | head -10

Repository: Rxup/space-station-14

Length of output: 47


🏁 Script executed:

# Check robustness library or look directly in the used interfaces
rg "IRobustRandom.*Pick" --type cs -A 10 | head -150

Repository: Rxup/space-station-14

Length of output: 47


🏁 Script executed:

# Search in the code for similar patterns where Pick is called on a list
rg "_random\.Pick.*bounds\|_random\.Pick.*list\|_random\.Pick.*\(\)" --type cs -B 3 -A 3

Repository: Rxup/space-station-14

Length of output: 47


🏁 Script executed:

# Look for ANY Pick usage with angle brackets (generic)
rg "\.Pick\<" --type cs | head -20

Repository: Rxup/space-station-14

Length of output: 145


🏁 Script executed:

# Try to understand the actual usage at the problem location
cat -n "Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs" | sed -n '50,75p'

Repository: Rxup/space-station-14

Length of output: 1368


🏁 Script executed:

# Search for IEnumerable/List Pick implementations across the repo
rg "Pick.*this.*IEnumerable|Pick.*this.*IList" --type cs -B 2 -A 5

Repository: Rxup/space-station-14

Length of output: 47


🏁 Script executed:

# Search for any other Pick method that might handle List
rg "Pick" "Content.Shared/Random/Helpers/SharedRandomExtensions.cs" --type cs

Repository: Rxup/space-station-14

Length of output: 1351


🏁 Script executed:

# Check if there's a generic Pick override that handles any enumerable
python3 << 'EOF'
import subprocess
import re

# Run rg to get all function definitions in the file
result = subprocess.run(
    ["rg", "public static.*Pick", "Content.Shared/Random/Helpers/SharedRandomExtensions.cs", "-A", "1"],
    capture_output=True,
    text=True
)

print("All Pick method signatures:")
print(result.stdout)
EOF

Repository: Rxup/space-station-14

Length of output: 151


🏁 Script executed:

# Look at what _random is - check the type definition
rg "_random" "Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs" -B 5 | head -30

Repository: Rxup/space-station-14

Length of output: 486


🏁 Script executed:

# Search for IRobustRandom usage and see what methods are available
rg "IRobustRandom.*Pick" --type cs -B 5 -A 10 | head -80

Repository: Rxup/space-station-14

Length of output: 47


🏁 Script executed:

# Let's check if there's a generic Pick method that works with List in the Robust.Shared library or elsewhere
# by looking at actual usages to understand the signature
rg "Pick\(.*List\|Pick\(.*bounds" --type cs -B 3 -A 3 | head -100

Repository: Rxup/space-station-14

Length of output: 47


🏁 Script executed:

# Search for how Pick is typically used with lists across the codebase
rg "_random\.Pick\(" --type cs -A 1 | grep -E "\.ToList|\.Values|bounds|entities" | head -30

Repository: Rxup/space-station-14

Length of output: 3362


🏁 Script executed:

# Try to understand by looking at test files
fd "RandomExtensionsTests" --type f | head -3

Repository: Rxup/space-station-14

Length of output: 118


🏁 Script executed:

cat -n "Content.Tests/Shared/Utility/RandomExtensionsTests.cs"

Repository: Rxup/space-station-14

Length of output: 1568


Добавьте проверку пустого списка перед вызовом _random.Pick.

Если у руины нет фиксчур с флагом Hard, список bounds будет пустым и вызов _random.Pick(bounds) выбросит исключение. Добавьте проверку, залогируйте ошибку, удалите временную сущность и верните false.

🐛 Предложенное исправление
            var bounds = (from fixture in manager.Fixtures.Values where fixture.Hard select fixture.Shape.ComputeAABB(transform, 0).Rounded(0)).ToList();
+           if (bounds.Count == 0)
+           {
+               Log.Error($"Ruin {ruin.ID} has no hard fixtures to compute bounds");
+               Del(spawned.Value);
+               return false;
+           }
            // Round this list of boxes up to
            var calculatedBox = _random.Pick(bounds);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Actually calculate ruin bound
var transform = _physics.GetRelativePhysicsTransform(spawned.Value, preloader.Owner);
// holy shit
var bounds = (from fixture in manager.Fixtures.Values where fixture.Hard select fixture.Shape.ComputeAABB(transform, 0).Rounded(0)).ToList();
// Round this list of boxes up to
var calculatedBox = _random.Pick(bounds);
foreach (var bound in bounds)
// Actually calculate ruin bound
var transform = _physics.GetRelativePhysicsTransform(spawned.Value, preloader.Owner);
// holy shit
var bounds = (from fixture in manager.Fixtures.Values where fixture.Hard select fixture.Shape.ComputeAABB(transform, 0).Rounded(0)).ToList();
if (bounds.Count == 0)
{
Log.Error($"Ruin {ruin.ID} has no hard fixtures to compute bounds");
Del(spawned.Value);
return false;
}
// Round this list of boxes up to
var calculatedBox = _random.Pick(bounds);
foreach (var bound in bounds)
🤖 Prompt for AI Agents
In `@Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs`
around lines 56 - 62, The code currently builds a list `bounds` from
`manager.Fixtures.Values` (filtering `fixture.Hard`) and then calls
`_random.Pick(bounds)` which will throw if `bounds` is empty; add a check after
computing `bounds` to handle empty lists: log an error mentioning the ruin
preloader/spawn (`spawned` and `preloader.Owner` or other contextual
identifiers), delete the temporary entity (`spawned.Value` or the same temp
entity used) and return false from the enclosing method; ensure you reference
the same variables used here (`_physics.GetRelativePhysicsTransform`,
`manager.Fixtures`, `fixture.Hard`, `_random.Pick`) so the fix is applied in the
same block.

Comment on lines +89 to +94
// If any used boundary intersects with current boundary, return
if (usedSpace.Any(used => used.Intersects(ruinBox)))
{
Log.Debug("Ruin can't be placed on it's coordinates, skipping spawn");
return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs | head -120 | tail -40

Repository: Rxup/space-station-14

Length of output: 186


🏁 Script executed:

cat -n Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs | head -150 | tail -80

Repository: Rxup/space-station-14

Length of output: 4440


🏁 Script executed:

wc -l Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs

Repository: Rxup/space-station-14

Length of output: 141


🏁 Script executed:

sed -n '1,150p' Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs | cat -n

Repository: Rxup/space-station-14

Length of output: 7653


Удалите временный грид при отказе размещения.

При первом вычислении границ грид загружается и помещается на карту preloader (строка 79). Если проверка пересечения (строка 90) обнаружит конфликт, метод возвращает false, но загруженный грид остаётся на preloader-карте, так как не удаляется. Это приводит к накоплению неиспользованных сущностей.

🐛 Исправление
         if (usedSpace.Any(used => used.Intersects(ruinBox)))
         {
             Log.Debug("Ruin can't be placed on it's coordinates, skipping spawn");
+            if (spawned != null)
+                Del(spawned.Value);
             return false;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If any used boundary intersects with current boundary, return
if (usedSpace.Any(used => used.Intersects(ruinBox)))
{
Log.Debug("Ruin can't be placed on it's coordinates, skipping spawn");
return false;
}
// If any used boundary intersects with current boundary, return
if (usedSpace.Any(used => used.Intersects(ruinBox)))
{
Log.Debug("Ruin can't be placed on it's coordinates, skipping spawn");
if (spawned != null)
Del(spawned.Value);
return false;
}
🤖 Prompt for AI Agents
In `@Content.Server/_Lavaland/Procedural/Systems/LavalandPlanetSystem.Grid.cs`
around lines 89 - 94, When the intersection check (usedSpace.Any(used =>
used.Intersects(ruinBox))) fails you must remove the temporary grid that was
loaded onto the preloader before returning false; locate the variable that holds
the loaded grid (the temporary grid/entity placed on the preloader map when
computing bounds) and ensure you call the appropriate removal method (e.g.
_entityManager.DeleteEntity(loadedGrid) or preloader.RemoveEntity(loadedGrid))
right before the early return in the failing branch so the unused grid entity is
not left on the preloader.

Comment on lines 34 to 38
private void OnBeforeDamageChanged(Entity<AggressiveComponent> ent, ref BeforeDamageChangedEvent args)
{
if (args.Origin == null)
if (args.Origin == null && !_actorQuery.HasComp(ent))
args.Cancelled = true;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Линия 36 отменяет урон по NPC от без-Origins источников.

Сейчас args.Cancelled = true срабатывает для урона без Origin по сущностям без ActorComponent, что делает NPC фактически неуязвимыми к средовым источникам (огонь, космос, ловушки). Если цель — лишь не учитывать агрессора, лучше не отменять урон и просто выйти из обработчика.

🛠️ Вариант правки
-    if (args.Origin == null && !_actorQuery.HasComp(ent))
-        args.Cancelled = true;
+    if (args.Origin == null || !_actorQuery.HasComp(args.Origin.Value))
+        return;
🤖 Prompt for AI Agents
In `@Content.Shared/_Lavaland/Aggression/SharedAggressorsSystem.cs` around lines
34 - 38, В обработчике OnBeforeDamageChanged не отменяй урон для сущностей без
ActorComponent на событиях с null Origin — вместо присваивания args.Cancelled =
true просто прекрати обработку и выйди из метода: проверку оставь (args.Origin
== null && !_actorQuery.HasComp(ent)) но не выставляй Cancelled; нужен лишь
ранний return в методе OnBeforeDamageChanged, чтобы не блокировать средовой
урон, при этом сохранил логику работы с BeforeDamageChangedEvent и метод/поле
_actorQuery.HasComp(ent).

Comment on lines +39 to +45
- preconditions:
- !type:LaydownPrecondition
isDown: true
tasks:
- !type:HTNPrimitiveTask
operator: !type:StandOperator
shutdownState: TaskFinished
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Несогласованность отступов в YAML.

Новая ветка использует 2-пробельные отступы для элементов списка (- !type:LaydownPrecondition), тогда как предыдущие ветки (строки 16-37) используют 4-пробельные отступы. Рекомендуется выровнять стиль для согласованности.

💚 Предлагаемое исправление отступов
     - preconditions:
-      - !type:LaydownPrecondition
-        isDown: true
-      tasks:
-      - !type:HTNPrimitiveTask
-        operator: !type:StandOperator
-          shutdownState: TaskFinished
+        - !type:LaydownPrecondition
+          isDown: true
+      tasks:
+        - !type:HTNPrimitiveTask
+          operator: !type:StandOperator
+            shutdownState: TaskFinished
🤖 Prompt for AI Agents
In `@Resources/Prototypes/NPCs/Combat/melee.yml` around lines 39 - 45, В файле
YAML есть несогласованность отступов: блоки списка под preconditions и tasks
используют 2 пробела, тогда как остальные элементы (строки 16–37) — 4 пробела;
приведите отступы к единому стилю (4 пробела) для записей -
!type:LaydownPrecondition, - !type:HTNPrimitiveTask и вложенных полей operator,
shutdownState: TaskFinished и isDown: true, сохранив структуру и вложенность.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Content.Shared/_White/BackStab/BackStabSystem.cs (1)

56-83: Исправьте вызывающие места из-за смены default alwaysBackstabLaying с true на false.

Параметр был изменён с дефолтного значения true на false. Это влияет на следующие вызовы:

  • BackStabSystem.cs:46TryBackstab(target, args.User, ent.Comp.Tolerance) — полагается на дефолт
  • DamageMarkerSystem.cs:40TryBackstab(uid, args.User, Angle.FromDegrees(45d), playSound: false) — полагается на дефолт

Явно передайте alwaysBackstabLaying: true в эти вызовы, если требуется прежнее поведение, или обновите логику в зависимости от желаемого результата.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@Content.Server/Zombies/ZombieSystem.Transform.cs`:
- Line 276: Вызов _bodySystem.ForceRestoreBody(target, true) использует явный
аргумент, совпадающий со значением параметра по умолчанию; упростите вызов,
убрав передачу true и вызвав _bodySystem.ForceRestoreBody(target) вместо
_bodySystem.ForceRestoreBody(target, true). Найдите использование
ForceRestoreBody в файле (в контексте переменной _bodySystem и параметра
removeWounds) и удалите явный true, оставив только target.

PainType.TraumaticPain,
nerveSys.Value);
}
_bodySystem.ForceRestoreBody(target, true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Step 1: Find the ForceRestoreBody method definition
echo "=== Searching for ForceRestoreBody method signature ==="
rg -n --type cs 'ForceRestoreBody' --max-count=20

echo ""
echo "=== Examining the ZombieSystem.Transform.cs file around line 276 ==="
# First, locate the file
find . -name "ZombieSystem.Transform.cs" -type f

Repository: Rxup/space-station-14

Length of output: 702


🏁 Script executed:

#!/bin/bash
# Step 2: Read the context around line 276 in ZombieSystem.Transform.cs
echo "=== Code context around line 276 ==="
sed -n '250,290p' ./Content.Server/Zombies/ZombieSystem.Transform.cs | cat -n

echo ""
echo "=== Full method signature of ForceRestoreBody ==="
sed -n '249,260p' ./Content.Shared/Body/Systems/SharedBodySystem.Body.cs | cat -n

echo ""
echo "=== Check broader context (lines 259-280) ==="
sed -n '259,280p' ./Content.Server/Zombies/ZombieSystem.Transform.cs | cat -n

Repository: Rxup/space-station-14

Length of output: 3522


Упростите вызов, используя значение параметра по умолчанию.

Параметр removeWounds в методе ForceRestoreBody имеет значение по умолчанию true. Вызов _bodySystem.ForceRestoreBody(target, true) на строке 276 можно упростить до _bodySystem.ForceRestoreBody(target) без явной передачи параметра, совпадающего с умолчанием.

Размещение вызова вне блока проверки нервной системы является намеренным — метод применяется ко всем зомбифицированным сущностям. Параметр removeWounds достаточно ясно описывает назначение.

🤖 Prompt for AI Agents
In `@Content.Server/Zombies/ZombieSystem.Transform.cs` at line 276, Вызов
_bodySystem.ForceRestoreBody(target, true) использует явный аргумент,
совпадающий со значением параметра по умолчанию; упростите вызов, убрав передачу
true и вызвав _bodySystem.ForceRestoreBody(target) вместо
_bodySystem.ForceRestoreBody(target, true). Найдите использование
ForceRestoreBody в файле (в контексте переменной _bodySystem и параметра
removeWounds) и удалите явный true, оставив только target.

@github-actions github-actions bot added size/L and removed size/M labels Jan 22, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs`:
- Around line 856-889: In ProcessGptResponse, when handling choices with
finish_reason "stop"/"length"/"blacklist" you currently only clear
uid.Comp.GptProcessing when gptMsg.message.content != null, leaving the flag
true for null content; change the flow so GptProcessing is set to false
regardless of whether gptMsg.message.content is null (e.g., add an else branch
that sets uid.Comp.GptProcessing = false or move that assignment after the
content-handling block), and keep the history.Lock.EnterWriteLock()/Add() logic
strictly inside the non-null-content branch so you don't acquire the lock for
null content; target the ProcessGptResponse method and the
gptMsg.message.content check to implement this fix.
- Around line 970-1006: In TryGptResponse the speaker name is pulled via
MetaData(speaker).EntityName which can bypass masking; change to resolve the
displayed identity using Identity.Name(speaker) before adding to the GPT history
(the GetOrCreateGptHistory/history.Add(new GptMessageChat(...)) call) so
masked/hidden identities are used when creating the GptMessageChat; ensure you
reference the same speaker EntityUid variable and remove direct use of
MetaData(speaker).EntityName.
- Around line 376-394: The idle-line selection in TryGetIdleChatLine incorrectly
pre-increments uid.Comp.IdleChatIndex causing the first entry in
uid.Comp.IdleChatOrder to be skipped; change the flow to read the current index
first and then advance it (or use post-increment) and only shuffle/reset when
the next index would exceed Count — e.g., compute var index =
uid.Comp.IdleChatOrder[uid.Comp.IdleChatIndex], then increment
uid.Comp.IdleChatIndex (or uid.Comp.IdleChatIndex++) and if it equals Count
reset to 0 and call _random.Shuffle(uid.Comp.IdleChatOrder); ensure you
reference TryGetIdleChatLine, IdleChatIndex, IdleChatOrder,
ConversationTree.Idle and _random.Shuffle when making the change.
🧹 Nitpick comments (1)
Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs (1)

1009-1028: Верните в GPT только сериализуемый/минимальный список тем.
Передача целых NPCTopic может раздуть payload и/или не сериализоваться. Лучше отдавать промпты и флаги.

♻️ Вариант упрощения payload
-                ev.History.Add(new GptMessageFunction("GetAvailableTopics", new { result = GetAvailableTopics(ev.NpcUid).ToArray() }));
+                var topics = GetAvailableTopics(ev.NpcUid)
+                    .Select(t => new { prompts = t.Prompts, locked = t.Locked, hidden = t.Hidden })
+                    .ToArray();
+                ev.History.Add(new GptMessageFunction("GetAvailableTopics", new { result = topics }));

Comment on lines +376 to 394
private bool TryGetIdleChatLine(Entity<NPCConversationComponent> uid, [NotNullWhen(true)] out NPCResponse? line)
{
line = null;

if (component.IdleChatOrder.Count() == 0)
if (!uid.Comp.IdleChatOrder.Any())
return false;

if (++component.IdleChatIndex == component.IdleChatOrder.Count())
if (++uid.Comp.IdleChatIndex == uid.Comp.IdleChatOrder.Count())
{
// Exhausted all lines in the pre-shuffled order.
// Reset the index and shuffle again.
component.IdleChatIndex = 0;
_random.Shuffle(component.IdleChatOrder);
uid.Comp.IdleChatIndex = 0;
_random.Shuffle(uid.Comp.IdleChatOrder);
}

var index = component.IdleChatOrder[component.IdleChatIndex];
var index = uid.Comp.IdleChatOrder[uid.Comp.IdleChatIndex];

line = component.ConversationTree.Idle[index];
line = uid.Comp.ConversationTree.Idle[index];

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Исправьте off‑by‑one в выборе idle‑фраз.
Сейчас первый элемент порядка никогда не используется из‑за ++IdleChatIndex до чтения.

🐛 Предлагаемый фикс
-        if (++uid.Comp.IdleChatIndex == uid.Comp.IdleChatOrder.Count())
+        if (uid.Comp.IdleChatIndex >= uid.Comp.IdleChatOrder.Count())
         {
             // Exhausted all lines in the pre-shuffled order.
             // Reset the index and shuffle again.
             uid.Comp.IdleChatIndex = 0;
             _random.Shuffle(uid.Comp.IdleChatOrder);
         }
 
-        var index = uid.Comp.IdleChatOrder[uid.Comp.IdleChatIndex];
+        var index = uid.Comp.IdleChatOrder[uid.Comp.IdleChatIndex++];
🤖 Prompt for AI Agents
In `@Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs` around lines 376
- 394, The idle-line selection in TryGetIdleChatLine incorrectly pre-increments
uid.Comp.IdleChatIndex causing the first entry in uid.Comp.IdleChatOrder to be
skipped; change the flow to read the current index first and then advance it (or
use post-increment) and only shuffle/reset when the next index would exceed
Count — e.g., compute var index =
uid.Comp.IdleChatOrder[uid.Comp.IdleChatIndex], then increment
uid.Comp.IdleChatIndex (or uid.Comp.IdleChatIndex++) and if it equals Count
reset to 0 and call _random.Shuffle(uid.Comp.IdleChatOrder); ensure you
reference TryGetIdleChatLine, IdleChatIndex, IdleChatOrder,
ConversationTree.Idle and _random.Shuffle when making the change.

Comment on lines 856 to 889
private async Task ProcessGptResponse(Entity<NPCConversationComponent> uid, NPCGptHistory history, GptResponseApi info)
{
foreach (var gptMsg in info.choices)
{
switch (gptMsg.finish_reason)
{
case "function_call":
await ProcessFunctionCall(uid, history, gptMsg);
break;
case "stop":
case "length":
case "blacklist":
if (gptMsg.message.content != null)
{
history.Lock.EnterWriteLock();
try
{
history.Add(new GptMessageChat(GptUserDirection.assistant, gptMsg.message.content));
}
finally
{
history.Lock.ExitWriteLock();
}

var response = new NPCResponse(gptMsg.message.content, null);
QueueResponse(uid.AsNullable(), response);
uid.Comp.GptProcessing = false;
}
break;
default:
_sawmill.Warning($"GPT вернул неподдерживаемый finish_reason: {gptMsg.finish_reason} для {ToPrettyString(uid)}");
uid.Comp.GptProcessing = false;
break;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Сбрасывайте GptProcessing даже при пустом content.
При finish_reason и content == null флаг остаётся true, и NPC больше не отвечает через GPT.

🐛 Предлагаемый фикс
                 case "stop":
                 case "length":
                 case "blacklist":
                     if (gptMsg.message.content != null)
                     {
                         history.Lock.EnterWriteLock();
                         try
                         {
                             history.Add(new GptMessageChat(GptUserDirection.assistant, gptMsg.message.content));
                         }
                         finally
                         {
                             history.Lock.ExitWriteLock();
                         }
 
                         var response = new NPCResponse(gptMsg.message.content, null);
                         QueueResponse(uid.AsNullable(), response);
-                        uid.Comp.GptProcessing = false;
                     }
+                    uid.Comp.GptProcessing = false;
                     break;
🤖 Prompt for AI Agents
In `@Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs` around lines 856
- 889, In ProcessGptResponse, when handling choices with finish_reason
"stop"/"length"/"blacklist" you currently only clear uid.Comp.GptProcessing when
gptMsg.message.content != null, leaving the flag true for null content; change
the flow so GptProcessing is set to false regardless of whether
gptMsg.message.content is null (e.g., add an else branch that sets
uid.Comp.GptProcessing = false or move that assignment after the
content-handling block), and keep the history.Lock.EnterWriteLock()/Add() logic
strictly inside the non-null-content branch so you don't acquire the lock for
null content; target the ProcessGptResponse method and the
gptMsg.message.content check to implement this fix.

Comment on lines 970 to 1006
private async Task TryGptResponse(Entity<NPCConversationComponent> uid, string message, EntityUid speaker)
{
if (!uid.Comp.UseGpt || uid.Comp.GptProcessing || !_gptEnabled)
return;

uid.Comp.GptProcessing = true;
try
{
var history = GetOrCreateGptHistory(uid);

history.Lock.EnterWriteLock();
try
{
var speakerName = MetaData(speaker).EntityName;
history.Add(new GptMessageChat(GptUserDirection.user, $"{speakerName}: {message}"));
}
finally
{
history.Lock.ExitWriteLock();
}

var (responseApi, err) = await SendGptApiRequest(history, uid);

if (err != null || responseApi == null)
{
_sawmill.Warning($"Не удалось получить ответ от GPT для {ToPrettyString(uid)}: {err}");
uid.Comp.GptProcessing = false;
return;
}

QueueResponse(uid, response, component);
await ProcessGptResponse(uid, history, responseApi);
}
catch (Exception ex)
{
_sawmill.Error($"Исключение при обработке GPT запроса для {ToPrettyString(uid)}: {ex}");
uid.Comp.GptProcessing = false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Не раскрывайте истинное имя при скрытии личности.
MetaData(speaker).EntityName может обойти механики маскировки и “протечь” в GPT‑ответ. Лучше использовать Identity.Name(...).

🔧 Предлагаемый фикс
+using Content.Shared.IdentityManagement;
@@
-                var speakerName = MetaData(speaker).EntityName;
+                var speakerName = Identity.Name(speaker, EntityManager, uid);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async Task TryGptResponse(Entity<NPCConversationComponent> uid, string message, EntityUid speaker)
{
if (!uid.Comp.UseGpt || uid.Comp.GptProcessing || !_gptEnabled)
return;
uid.Comp.GptProcessing = true;
try
{
var history = GetOrCreateGptHistory(uid);
history.Lock.EnterWriteLock();
try
{
var speakerName = MetaData(speaker).EntityName;
history.Add(new GptMessageChat(GptUserDirection.user, $"{speakerName}: {message}"));
}
finally
{
history.Lock.ExitWriteLock();
}
var (responseApi, err) = await SendGptApiRequest(history, uid);
if (err != null || responseApi == null)
{
_sawmill.Warning($"Не удалось получить ответ от GPT для {ToPrettyString(uid)}: {err}");
uid.Comp.GptProcessing = false;
return;
}
QueueResponse(uid, response, component);
await ProcessGptResponse(uid, history, responseApi);
}
catch (Exception ex)
{
_sawmill.Error($"Исключение при обработке GPT запроса для {ToPrettyString(uid)}: {ex}");
uid.Comp.GptProcessing = false;
}
private async Task TryGptResponse(Entity<NPCConversationComponent> uid, string message, EntityUid speaker)
{
if (!uid.Comp.UseGpt || uid.Comp.GptProcessing || !_gptEnabled)
return;
uid.Comp.GptProcessing = true;
try
{
var history = GetOrCreateGptHistory(uid);
history.Lock.EnterWriteLock();
try
{
var speakerName = Identity.Name(speaker, EntityManager, uid);
history.Add(new GptMessageChat(GptUserDirection.user, $"{speakerName}: {message}"));
}
finally
{
history.Lock.ExitWriteLock();
}
var (responseApi, err) = await SendGptApiRequest(history, uid);
if (err != null || responseApi == null)
{
_sawmill.Warning($"Не удалось получить ответ от GPT для {ToPrettyString(uid)}: {err}");
uid.Comp.GptProcessing = false;
return;
}
await ProcessGptResponse(uid, history, responseApi);
}
catch (Exception ex)
{
_sawmill.Error($"Исключение при обработке GPT запроса для {ToPrettyString(uid)}: {ex}");
uid.Comp.GptProcessing = false;
}
🤖 Prompt for AI Agents
In `@Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs` around lines 970
- 1006, In TryGptResponse the speaker name is pulled via
MetaData(speaker).EntityName which can bypass masking; change to resolve the
displayed identity using Identity.Name(speaker) before adding to the GPT history
(the GetOrCreateGptHistory/history.Add(new GptMessageChat(...)) call) so
masked/hidden identities are used when creating the GptMessageChat; ensure you
reference the same speaker EntityUid variable and remove direct use of
MetaData(speaker).EntityName.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs`:
- Around line 713-753: NPCGptHistory currently holds a ReaderWriterLockSlim that
is never disposed and its methods are inconsistent about locking; make
NPCGptHistory implement IDisposable to dispose Lock, change Add to acquire and
release Lock.EnterWriteLock/ExitWriteLock internally, change read-only accessors
(IsCanAnswer, GetMessagesForApi) to use Lock.EnterReadLock/ExitReadLock, keep
the constructor behavior but avoid holding the lock longer than necessary, and
ensure caller code (e.g., OnComponentRemove/_npcGptHistory removal) calls
Dispose on the removed NPCGptHistory (or remove and dispose the instance) so the
ReaderWriterLockSlim is released properly.
- Around line 946-956: Add a depth limit to prevent unbounded recursive
function_call handling: introduce a max depth constant (e.g.,
MAX_FUNCTION_CALL_DEPTH) and a counter stored on the NPC conversation context or
component (e.g., a new property like GptFunctionCallDepth on uid.Comp) or passed
as an explicit parameter through SendGptApiRequest → ProcessGptResponse →
ProcessFunctionCall; increment the counter each time ProcessFunctionCall is
entered and check it before making a recursive SendGptApiRequest call, log and
stop processing (set uid.Comp.GptProcessing = false) if the counter exceeds the
limit to avoid infinite recursion or stack overflows.
♻️ Duplicate comments (3)
Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs (3)

371-381: Off-by-one ошибка в выборе idle-фраз всё ещё присутствует.

Pre-increment ++uid.Comp.IdleChatIndex перед чтением приводит к пропуску первого элемента IdleChatOrder.


853-872: GptProcessing не сбрасывается при пустом content — проблема всё ещё присутствует.

При finish_reason равном "stop"/"length"/"blacklist" и content == null, флаг GptProcessing остаётся true, блокируя дальнейшие GPT-ответы для этого NPC.


968-977: MetaData(speaker).EntityName обходит механики маскировки — проблема всё ещё присутствует.

Использование MetaData(speaker).EntityName раскрывает истинное имя игрока в GPT-истории, игнорируя маски и переодевания. Согласно relevant code snippet, следует использовать Identity.Name(speaker, EntityManager, uid).

🧹 Nitpick comments (3)
Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs (3)

52-81: Потенциальные проблемы с SSL и потокобезопасностью HttpClient.

  1. SSL callback принимает сертификаты с UntrustedRoot — это намеренное решение для self-signed сертификатов, но следует задокументировать причину.

  2. _gptHttpClient — instance field, но DefaultRequestHeaders.Authorization модифицируется из разных мест (GptTokenCVarChanged на линии 173 и SendGptApiRequest на линии 823). При параллельных запросах возможна гонка данных.

Рекомендуется передавать токен через HttpRequestMessage.Headers вместо DefaultRequestHeaders:

♻️ Предлагаемый рефакторинг
 private async Task<(GptResponseApi? responseApi, string? err)> SendGptApiRequest(NPCGptHistory history, EntityUid uid)
 {
     // ...
     var postData = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
     postData.Headers.Add("X-Session-ID", uid.ToString());

-    if (!string.IsNullOrEmpty(_gptApiToken))
-    {
-        _gptHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _gptApiToken);
-    }
-
-    var request = await _gptHttpClient.PostAsync(...);
+    using var request = new HttpRequestMessage(HttpMethod.Post, $"{_gptApiUrl + (_gptApiUrl.EndsWith("/") ? "" : "/")}chat/completions");
+    request.Content = postData;
+    if (!string.IsNullOrEmpty(_gptApiToken))
+    {
+        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _gptApiToken);
+    }
+    var response = await _gptHttpClient.SendAsync(request);

636-651: Fire-and-forget async вызовы могут терять исключения.

Дискардинг _ = TryGptResponse(...) отбрасывает Task. Хотя внутри TryGptResponse есть try-catch, любые исключения в async-продолжениях (например, в ProcessGptResponse) могут быть потеряны.

Рекомендуется добавить ContinueWith для логирования необработанных исключений:

♻️ Предлагаемый рефакторинг
-_ = TryGptResponse(uid, args.Message, args.Source);
+TryGptResponse(uid, args.Message, args.Source).ContinueWith(t =>
+{
+    if (t.Exception != null)
+        Log.Error($"Unhandled GPT exception: {t.Exception}");
+}, TaskContinuationOptions.OnlyOnFaulted);

770-800: Отсутствует буфер времени при проверке истечения токена GigaChat.

Линия 774: if(_gigaTocExpire > DateTimeOffset.Now) — токен может истечь во время выполнения запроса. Рекомендуется добавить буфер:

♻️ Предлагаемый рефакторинг
-if(_gigaTocExpire > DateTimeOffset.Now)
+if(_gigaTocExpire > DateTimeOffset.Now.AddSeconds(60))
     return;

Comment on lines +713 to +753
public sealed class NPCGptHistory
{
public List<GptMessage> Messages { get; } = new();
public readonly ReaderWriterLockSlim Lock = new();

public NPCGptHistory(string systemPrompt)
{
Lock.EnterWriteLock();
try
{
if (!string.IsNullOrEmpty(systemPrompt))
{
Messages.Add(new GptMessageChat(GptUserDirection.system, systemPrompt));
}
}
finally
{
Lock.ExitWriteLock();
}
}

public void Add(GptMessage msg)
{
Messages.Add(msg);

if (Messages.Count > 100)
{
Messages.RemoveRange(0, Messages.Count - 100);
}
}

public bool IsCanAnswer()
{
return Messages.Count > 0 && Messages.Last().Role == GptUserDirection.user;
}

public object[] GetMessagesForApi()
{
return Messages.Select(x => x.ToApi()).ToArray();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Несогласованность блокировок и утечка ReaderWriterLockSlim.

  1. ReaderWriterLockSlim реализует IDisposable, но никогда не освобождается при удалении истории из _npcGptHistory (линии 356, 361).

  2. Метод Add (линия 734) не захватывает блокировку, хотя конструктор (линия 720) делает это сам. Вызывающий код должен сам захватывать Lock.EnterWriteLock() перед вызовом Add, что несогласованно.

🐛 Предлагаемый фикс для Add
 public void Add(GptMessage msg)
 {
+    // Caller must hold write lock
+    DebugTools.Assert(Lock.IsWriteLockHeld, "Write lock must be held when calling Add");
     Messages.Add(msg);

     if (Messages.Count > 100)
     {
         Messages.RemoveRange(0, Messages.Count - 100);
     }
 }

Также добавьте Dispose при удалении из словаря:

private void OnComponentRemove(EntityUid uid, NPCConversationComponent component, ComponentRemove args)
{
    if (_npcGptHistory.Remove(uid, out var history))
        history.Lock.Dispose();
}
🤖 Prompt for AI Agents
In `@Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs` around lines 713
- 753, NPCGptHistory currently holds a ReaderWriterLockSlim that is never
disposed and its methods are inconsistent about locking; make NPCGptHistory
implement IDisposable to dispose Lock, change Add to acquire and release
Lock.EnterWriteLock/ExitWriteLock internally, change read-only accessors
(IsCanAnswer, GetMessagesForApi) to use Lock.EnterReadLock/ExitReadLock, keep
the constructor behavior but avoid holding the lock longer than necessary, and
ensure caller code (e.g., OnComponentRemove/_npcGptHistory removal) calls
Dispose on the removed NPCGptHistory (or remove and dispose the instance) so the
ReaderWriterLockSlim is released properly.

Comment on lines +946 to +956
// Продолжаем обработку после вызова функции
var (responseApi, err) = await SendGptApiRequest(history, uid);
if (err != null || responseApi == null)
{
Log.Warning($"Не удалось получить ответ от GPT после функции для {ToPrettyString(uid)}: {err}");
uid.Comp.GptProcessing = false;
return;
}

await ProcessGptResponse(uid, history, responseApi);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Отсутствует ограничение глубины рекурсии для function calls.

ProcessFunctionCall рекурсивно вызывает SendGptApiRequestProcessGptResponseProcessFunctionCall. При некорректном ответе GPT (постоянные function_call) возможна бесконечная рекурсия или глубокий стек.

Рекомендуется добавить счётчик глубины или лимит вызовов функций:

♻️ Предлагаемый рефакторинг
-private async Task ProcessGptResponse(Entity<NPCConversationComponent> uid, NPCGptHistory history, GptResponseApi info)
+private async Task ProcessGptResponse(Entity<NPCConversationComponent> uid, NPCGptHistory history, GptResponseApi info, int depth = 0)
 {
+    const int MaxFunctionCallDepth = 5;
+    if (depth >= MaxFunctionCallDepth)
+    {
+        Log.Warning($"GPT function call depth exceeded for {ToPrettyString(uid)}");
+        uid.Comp.GptProcessing = false;
+        return;
+    }
     foreach (var gptMsg in info.choices)
     {
         switch (gptMsg.finish_reason)
         {
             case "function_call":
-                await ProcessFunctionCall(uid, history, gptMsg);
+                await ProcessFunctionCall(uid, history, gptMsg, depth);
                 break;
🤖 Prompt for AI Agents
In `@Content.Server/Backmen/NPC/Systems/NPCConversationSystem.cs` around lines 946
- 956, Add a depth limit to prevent unbounded recursive function_call handling:
introduce a max depth constant (e.g., MAX_FUNCTION_CALL_DEPTH) and a counter
stored on the NPC conversation context or component (e.g., a new property like
GptFunctionCallDepth on uid.Comp) or passed as an explicit parameter through
SendGptApiRequest → ProcessGptResponse → ProcessFunctionCall; increment the
counter each time ProcessFunctionCall is entered and check it before making a
recursive SendGptApiRequest call, log and stop processing (set
uid.Comp.GptProcessing = false) if the counter exceeds the limit to avoid
infinite recursion or stack overflows.

@Rxup Rxup merged commit d41c6b9 into master Jan 22, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant