From 0e89e10a624d39808055be5bbc4e1ca2daf44278 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 2 May 2026 11:19:18 +0500 Subject: [PATCH 1/3] Fix zombie sessions caused by unhandled exceptions in ZoneSession logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogoutPlayer() ran inside a TransactionScope with no exception handling: on any DB failure the method exited without calling Disconnect/OnStopped, leaving _isInLogout=true with no recovery path and the character marked online permanently in the database. - Wrap LogoutPlayer() in try/catch/finally so Disconnect() and OnStopped() always execute; catch block ensures in-memory state (RemoveFromZone, SetSession) is cleaned up even when the transaction is rolled back. - Remove LogoutRequest(false) from Disconnect() — it incorrectly created a new logout timer after logout had already completed (timer was cleared before Task.Run). OnDisconnected already starts the timer for raw TCP disconnections. - Guard OnDisconnected against creating a logout timer when _isInLogout is already true to prevent a spurious second timer during session teardown. Co-Authored-By: Claude Sonnet 4.6 --- src/Perpetuum/Zones/ZoneSession.cs | 38 +++++++++++++++++++----------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/Perpetuum/Zones/ZoneSession.cs b/src/Perpetuum/Zones/ZoneSession.cs index ae0a4a6..3f9049f 100644 --- a/src/Perpetuum/Zones/ZoneSession.cs +++ b/src/Perpetuum/Zones/ZoneSession.cs @@ -115,7 +115,7 @@ private void OnDisconnected(ITcpConnection connection) { OnStopped(); } - else + else if (!_isInLogout) { DisconnectTime = DateTime.Now; //for the logs LogoutRequest(false); @@ -211,6 +211,7 @@ private void OnReceived(ITcpConnection connection, byte[] packetData) TimeSpan workTime = GlobalTimer.Elapsed - executeTime; packet.WorkTime = (int)workTime.TotalMilliseconds; SendPacket(packet); + WritePacketLog(packet, $"workTime = {workTime.TotalMilliseconds} ms"); if (cancelLogout) { @@ -946,28 +947,37 @@ private void UpdateLogout(TimeSpan time) private void LogoutPlayer() { Character character = Character; - - using (TransactionScope scope = Db.CreateTransaction()) + try { - _player.DynamicProperties.Update(k.armor, _player.Armor.Ratio(_player.ArmorMax)); - _player.Save(); - character.ZoneId = _zone.Id; - character.ZonePosition = _player.CurrentPosition; + using (TransactionScope scope = Db.CreateTransaction()) + { + _player.DynamicProperties.Update(k.armor, _player.Armor.Ratio(_player.ArmorMax)); + _player.Save(); + character.ZoneId = _zone.Id; + character.ZonePosition = _player.CurrentPosition; + _player.RemoveFromZone(); + _player.SetSession(None); + + _sessionManager.DeselectCharacter(character); + scope.Complete(); + } + } + catch (Exception ex) + { + Logger.Exception(ex); _player.RemoveFromZone(); _player.SetSession(None); - - _sessionManager.DeselectCharacter(character); - scope.Complete(); } - - Disconnect(); - OnStopped(); + finally + { + Disconnect(); + OnStopped(); + } } public void Disconnect() { - LogoutRequest(false); _connection?.Disconnect(); } From 1bb5635ba4102230e092b860de3e6faf907e1741 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 2 May 2026 12:16:31 +0500 Subject: [PATCH 2/3] Add SupportAI for NPCs equipped with remote support modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPCs that carry a Remote Armor Repairer or Energy Transferer now switch into a dedicated SupportAI state when any non-hostile NPC drops below 75% armor or energy. The most-needy ally (lowest %) is prioritized; the support NPC paths into module range with combat-style A* pathing, acquires a primary lock on the friend, and ticks support module activators that bypass the visibility set (same-faction friends are not visibility-tracked) by checking LOS directly through the zone. While in SupportAI, the NPC ignores enemies — combat module activators do not run and combat locks are dropped on Enter. AggressorAI's existing ShouldFlee branch still wins, so support NPCs flee to safety when their own armor or core gets low. AggressorAI re-acquires combat locks naturally on resume via CombatAI.UpdateHostiles after SupportAI pops. Friend discovery combines flock members with visible non-hostile SmartCreatures, covering both same-faction allies (Niani+Niani) and cross-faction allies (Niani+Cultist, both non-Syndicate per Npc.IsHostile). NPCs without remote support modules short-circuit on IsSupportCapable, so existing behavior is unchanged for them. Co-Authored-By: Claude Sonnet 4.6 --- .../Zones/NpcSystem/AI/AggressorAI.cs | 7 + src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs | 7 + src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs | 356 ++++++++++++++++++ .../NpcSystem/AI/SupportModuleActivator.cs | 56 +++ .../Zones/NpcSystem/SmartCreature.cs | 73 ++++ 5 files changed, 499 insertions(+) create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs diff --git a/src/Perpetuum/Zones/NpcSystem/AI/AggressorAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/AggressorAI.cs index 2fc2e1f..969d1e2 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/AggressorAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/AggressorAI.cs @@ -29,6 +29,13 @@ public override void Update(TimeSpan time) return; } + if (smartCreature.HasFriendsNeedingSupport()) + { + smartCreature.AI.Push(new SupportAI(smartCreature)); + + return; + } + if (!smartCreature.ThreatManager.IsThreatened) { ReturnToHomePosition(); diff --git a/src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs index c96da17..cd3a424 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs @@ -31,6 +31,13 @@ public override void Update(TimeSpan time) return; } + if (smartCreature.HasFriendsNeedingSupport()) + { + smartCreature.AI.Push(new SupportAI(smartCreature)); + + return; + } + if (smartCreature.ThreatManager.IsThreatened) { ToAggressorAI(); diff --git a/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs new file mode 100644 index 0000000..24a80d9 --- /dev/null +++ b/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs @@ -0,0 +1,356 @@ +using Perpetuum.Collections; +using Perpetuum.Modules; +using Perpetuum.PathFinders; +using Perpetuum.Timers; +using Perpetuum.Units; +using Perpetuum.Zones.Locking.Locks; +using Perpetuum.Zones.Movements; +using Perpetuum.Zones.Terrains; +using System.Drawing; + +namespace Perpetuum.Zones.NpcSystem.AI +{ + public class SupportAI : BaseAI + { + private const double SupportThreshold = 0.75; + private const int RepathFrequencyMs = 1500; + private const int Sqrt2 = 141; + private const int Weight = 1000; + + private readonly List supportActivators = new(); + private readonly bool canRepair; + private readonly bool canTransfer; + private readonly double supportRange; + + private readonly IntervalTimer repathTimer = new(TimeSpan.FromMilliseconds(RepathFrequencyMs), true); + + private SmartCreature currentTarget; + private Position lastTargetPosition; + private PathMovement movement; + private PathMovement nextMovement; + private CancellationTokenSource source; + + public SupportAI(SmartCreature smartCreature) : base(smartCreature) + { + List repairers = smartCreature.ActiveModules + .OfType() + .ToList(); + List transferers = smartCreature.ActiveModules + .OfType() + .ToList(); + + canRepair = repairers.Count > 0; + canTransfer = transferers.Count > 0; + + foreach (RemoteArmorRepairModule m in repairers) + { + supportActivators.Add(new SupportModuleActivator(m)); + } + + foreach (EnergyTransfererModule m in transferers) + { + supportActivators.Add(new SupportModuleActivator(m)); + } + + // Use the smallest module range as the engagement range so we know we're in + // range of every equipped support module before we stop and fire. + double minRange = double.MaxValue; + foreach (SupportModuleActivator activator in supportActivators) + { + double r = activator.Module.OptimalRange + activator.Module.Falloff; + if (r < minRange) + { + minRange = r; + } + } + + supportRange = minRange < double.MaxValue ? minRange : 0; + } + + public override void Enter() + { + // Drop any combat locks that AggressorAI/IdleAI may have acquired so the + // support module activators only ever see friendly locks. AggressorAI + // re-acquires combat locks on resume via CombatAI.UpdateHostiles. + smartCreature.ResetLocks(); + base.Enter(); + } + + public override void Exit() + { + source?.Cancel(); + base.Exit(); + } + + public override void Update(TimeSpan time) + { + if (smartCreature.ShouldFlee()) + { + smartCreature.AI.Push(new FleeAI(smartCreature)); + + return; + } + + if (supportActivators.Count == 0) + { + _ = smartCreature.AI.Pop(); + + return; + } + + SmartCreature target = SelectSupportTarget(); + if (target == null) + { + _ = smartCreature.AI.Pop(); + + return; + } + + UpdateMovement(target, time); + + if (smartCreature.IsInLockingRange(target)) + { + EnsureLock(target); + RunSupportModules(time); + } + else if (currentTarget != null) + { + smartCreature.ResetLocks(); + currentTarget = null; + } + } + + // SupportAI shouldn't auto-promote sideways into combat or homing — those + // transitions are owned by AggressorAI/IdleAI underneath us. + protected override void ToHomeAI() { } + protected override void ToAggressorAI() { } + + private SmartCreature SelectSupportTarget() + { + SmartCreature best = null; + double bestNeed = 0; + + foreach (SmartCreature candidate in smartCreature.GetSupportCandidates()) + { + double need = 0; + + if (canRepair) + { + double a = candidate.ArmorPercentage; + if (a < SupportThreshold) + { + need = Math.Max(need, 1.0 - a); + } + } + + if (canTransfer) + { + double c = candidate.CorePercentage; + if (c < SupportThreshold) + { + need = Math.Max(need, 1.0 - c); + } + } + + if (need <= 0) + { + continue; + } + + if (need > bestNeed) + { + bestNeed = need; + best = candidate; + } + } + + return best; + } + + private void EnsureLock(SmartCreature target) + { + if (currentTarget == target) + { + UnitLock existing = smartCreature.GetLockByUnit(target); + if (existing != null) + { + if (!existing.Primary) + { + smartCreature.SetPrimaryLock(existing.Id); + } + + return; + } + } + + // Target switched — drop old lock, acquire new one as primary. + smartCreature.ResetLocks(); + smartCreature.AddLock(target, true); + currentTarget = target; + } + + private void RunSupportModules(TimeSpan time) + { + UnitLock supportLock = currentTarget != null ? smartCreature.GetLockByUnit(currentTarget) : null; + + foreach (SupportModuleActivator activator in supportActivators) + { + activator.Update(time, supportLock); + } + } + + private void UpdateMovement(SmartCreature target, TimeSpan time) + { + bool inRange = smartCreature.IsInRangeOf3D(target, supportRange * 0.9); + _ = repathTimer.Update(time); + + if (inRange) + { + if (movement != null) + { + smartCreature.StopMoving(); + movement = null; + } + + return; + } + + bool targetMoved = !target.CurrentPosition.IsEqual2D(lastTargetPosition); + + if (movement == null || targetMoved || repathTimer.Passed) + { + repathTimer.Reset(); + lastTargetPosition = target.CurrentPosition; + + _ = FindNewSupportPositionAsync(target).ContinueWith(t => + { + if (t.IsCanceled) + { + return; + } + + List path = t.Result; + if (path == null) + { + return; + } + + _ = Interlocked.Exchange(ref nextMovement, new PathMovement(path)); + }); + } + + if (nextMovement != null) + { + movement = Interlocked.Exchange(ref nextMovement, null); + movement.Start(smartCreature); + } + + movement?.Update(smartCreature, time); + } + + private Task> FindNewSupportPositionAsync(Unit target) + { + source?.Cancel(); + source = new CancellationTokenSource(); + + return Task.Run(() => FindSupportPosition(target, source.Token), source.Token); + } + + // Modeled on CombatAI.FindNewAttackPosition — A* over walkable tiles inside the + // creature's home range, looking for a tile within the smallest equipped support + // module's range with LOS to the target. + private List FindSupportPosition(Unit target, CancellationToken cancellationToken) + { + int approachRange = (int)Math.Max(1, supportRange * 0.7); + Point end = target.CurrentPosition.GetRandomPositionInRange2D(0, approachRange).ToPoint(); + + smartCreature.StopMoving(); + movement = null; + + double maxNode = Math.Pow(smartCreature.HomeRange, 2) * Math.PI; + PriorityQueue priorityQueue = new((int)maxNode); + Node startNode = new(smartCreature.CurrentPosition); + + priorityQueue.Enqueue(startNode); + + HashSet closed = + [ + startNode.position + ]; + + while (priorityQueue.TryDequeue(out Node current)) + { + if (cancellationToken.IsCancellationRequested) + { + return null; + } + + if (IsValidSupportPosition(target, current.position)) + { + return BuildPath(current); + } + + foreach (Point n in current.position.GetNeighbours()) + { + if (closed.Contains(n)) + { + continue; + } + + _ = closed.Add(n); + + if (!smartCreature.IsWalkable(n.X, n.Y)) + { + continue; + } + + if (!n.IsInRange(smartCreature.HomePosition, smartCreature.HomeRange)) + { + continue; + } + + int newG = current.g + (n.X - current.position.X == 0 || n.Y - current.position.Y == 0 ? 100 : Sqrt2); + int newH = Heuristic.Manhattan.Calculate(n.X, n.Y, end.X, end.Y) * Weight; + Node newNode = new(n) + { + g = newG, + f = newG + newH, + parent = current + }; + + priorityQueue.Enqueue(newNode); + } + } + + return null; + } + + private bool IsValidSupportPosition(Unit target, Point position) + { + Position position3 = smartCreature.Zone.FixZ(position.ToPosition()).AddToZ(smartCreature.Height); + + if (!target.CurrentPosition.IsInRangeOf3D(position3, supportRange * 0.9)) + { + return false; + } + + LOSResult r = smartCreature.Zone.IsInLineOfSight(position3, target, false); + + return !r.hit; + } + + private static List BuildPath(Node current) + { + Stack stack = new(); + Node node = current; + + while (node != null) + { + stack.Push(node.position); + node = node.parent; + } + + return stack.ToList(); + } + } +} diff --git a/src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs b/src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs new file mode 100644 index 0000000..dcb6625 --- /dev/null +++ b/src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs @@ -0,0 +1,56 @@ +using Perpetuum.Modules; +using Perpetuum.Timers; +using Perpetuum.Zones.Locking; +using Perpetuum.Zones.Locking.Locks; +using Perpetuum.Zones.Terrains; +using System; + +namespace Perpetuum.Zones.NpcSystem.AI +{ + public sealed class SupportModuleActivator + { + private readonly ActiveModule module; + private readonly IntervalTimer timer = new(TimeSpan.FromSeconds(1), true); + + public SupportModuleActivator(ActiveModule module) + { + this.module = module; + } + + public ActiveModule Module => module; + + public void Update(TimeSpan time, UnitLock supportLock) + { + _ = timer.Update(time); + + if (!timer.Passed) + { + return; + } + + timer.Reset(); + + if (module.State.Type != ModuleStateType.Idle) + { + return; + } + + if (supportLock == null || supportLock.State != LockState.Locked) + { + return; + } + + // Friendly targets are not in the visibility set (Npc.UpdateUnitVisibility + // only tracks other-faction NPCs), so we cannot use IUnitVisibility here — + // query LOS directly via the zone instead. + LOSResult los = module.ParentRobot.Zone.IsInLineOfSight(module.ParentRobot, supportLock.Target, false); + if (los != null && los.hit) + { + return; + } + + module.Lock = supportLock; + module.State.SwitchTo(ModuleStateType.Oneshot); + } + } +} diff --git a/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs b/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs index 52c859e..e7e75ef 100644 --- a/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs +++ b/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs @@ -1,6 +1,7 @@ using Perpetuum.EntityFramework; using Perpetuum.ExportedTypes; using Perpetuum.Items; +using Perpetuum.Modules; using Perpetuum.Players; using Perpetuum.StateMachines; using Perpetuum.Timers; @@ -400,6 +401,78 @@ public virtual bool IsFriendly(Unit source) return false; } + public const double SupportThreshold = 0.75; + + public bool HasRemoteArmorRepairer => ActiveModules.OfType().Any(); + + public bool HasEnergyTransferer => ActiveModules.OfType().Any(); + + public bool IsSupportCapable => HasRemoteArmorRepairer || HasEnergyTransferer; + + public IEnumerable GetSupportCandidates() + { + HashSet seen = new(); + + ISmartCreatureGroup group = Group; + if (group != null) + { + foreach (SmartCreature member in group.Members) + { + if (member == this || member.States.Dead) + { + continue; + } + + if (seen.Add(member.Eid)) + { + yield return member; + } + } + } + + // Cross-faction friends (e.g. Niani <-> Cultist) aren't in the same group + // but show up in the visibility set because they belong to a different + // faction. IsHostile gives us the friend/foe verdict consistent with the + // existing Npc.IsHostile(Npc) faction logic. + foreach (IUnitVisibility visibility in GetVisibleUnits()) + { + if (visibility.Target is SmartCreature creature && + creature != this && + !creature.States.Dead && + !IsHostile(creature) && + seen.Add(creature.Eid)) + { + yield return creature; + } + } + } + + public bool HasFriendsNeedingSupport(double threshold = SupportThreshold) + { + if (!IsSupportCapable) + { + return false; + } + + bool canRepair = HasRemoteArmorRepairer; + bool canTransfer = HasEnergyTransferer; + + foreach (SmartCreature candidate in GetSupportCandidates()) + { + if (canRepair && candidate.ArmorPercentage < threshold) + { + return true; + } + + if (canTransfer && candidate.CorePercentage < threshold) + { + return true; + } + } + + return false; + } + protected override void OnTileChanged() { base.OnTileChanged(); From 7605e4b767a305b2ddbcb22e04fe939e576e4c55 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 2 May 2026 16:16:10 +0500 Subject: [PATCH 3/3] Fix SupportAI: combat misuse, movement race, per-target module choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues surfaced during in-zone testing: 1) Support modules fired on enemies during combat. CombatAI.Enter built a ModuleActivator for every equipped module — the standard Visit methods for RemoteArmorRepair/EnergyTransferer pick whatever is locked, which in combat is the enemy. Filter both module types out of CombatAI's activator list; SupportAI owns them. 2) NullReferenceException at the movement-start path and bot failing to follow its lock target. Two compounding bugs: - FindSupportPosition (worker thread) wrote `smartCreature.StopMoving()` and `movement = null` directly to instance fields. When a second worker started while the first's continuation had just populated `nextMovement`, the AI tick could read the swapped pending movement, then have it nulled by the worker before .Start ran. - The repath trigger used `movement == null` so the AI tick re-issued (and cancelled) the path search every frame until a path materialized, which it never did. Mirror CombatAI's trigger condition (target moved or periodic timer), gate it with a pathPending flag like FleeAI uses, drop the cross-thread mutations, add a defensive null check on the Interlocked.Exchange result, and wrap FindSupportPosition in a try/catch so worker exceptions don't fault the task. 3) Bot ran both repair and energy transfer regardless of need. Split activators into repair/transfer lists and only tick the kind whose corresponding stat on the current target is actually below the support threshold. Co-Authored-By: Claude Sonnet 4.6 --- src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs | 16 +- .../Zones/NpcSystem/AI/ModuleActivator.cs | 1 - src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs | 245 ++++++++++++------ .../NpcSystem/AI/SupportModuleActivator.cs | 10 +- .../Zones/NpcSystem/SmartCreature.cs | 7 + 5 files changed, 188 insertions(+), 91 deletions(-) diff --git a/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs index ef65fa1..511b395 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs @@ -1,4 +1,5 @@ using Perpetuum.Collections; +using Perpetuum.Modules; using Perpetuum.Modules.Weapons; using Perpetuum.PathFinders; using Perpetuum.Timers; @@ -39,9 +40,7 @@ public CombatAI(SmartCreature smartCreature) : base(smartCreature) { } public override void Enter() { stratSelector = InitSelector(); - moduleActivators = smartCreature.ActiveModules - .Select(m => new ModuleActivator(m)) - .ToList(); + moduleActivators = BuildCombatModuleActivators(); IsNpcHasMissiles = smartCreature.ActiveModules .OfType() .Any(); @@ -53,7 +52,16 @@ public override void Enter() protected override List FillModuleActivators() { - return moduleActivators = smartCreature.ActiveModules + return moduleActivators = BuildCombatModuleActivators(); + } + + // Remote support modules are owned exclusively by SupportAI — without this + // filter the standard ModuleActivator visitors would happily target the + // current combat lock, healing/energy-feeding the enemy. + private List BuildCombatModuleActivators() + { + return smartCreature.ActiveModules + .Where(m => m is not RemoteArmorRepairModule && m is not EnergyTransfererModule) .Select(m => new ModuleActivator(m)) .ToList(); } diff --git a/src/Perpetuum/Zones/NpcSystem/AI/ModuleActivator.cs b/src/Perpetuum/Zones/NpcSystem/AI/ModuleActivator.cs index 1438ddf..7b51429 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/ModuleActivator.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/ModuleActivator.cs @@ -5,7 +5,6 @@ using Perpetuum.Timers; using Perpetuum.Zones.Locking.Locks; using Perpetuum.Zones.Terrains; -using System; namespace Perpetuum.Zones.NpcSystem.AI { diff --git a/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs index 24a80d9..c3e5b70 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs @@ -1,11 +1,11 @@ using Perpetuum.Collections; using Perpetuum.Modules; +using Perpetuum.Modules.EffectModules; using Perpetuum.PathFinders; using Perpetuum.Timers; using Perpetuum.Units; using Perpetuum.Zones.Locking.Locks; using Perpetuum.Zones.Movements; -using Perpetuum.Zones.Terrains; using System.Drawing; namespace Perpetuum.Zones.NpcSystem.AI @@ -17,7 +17,9 @@ public class SupportAI : BaseAI private const int Sqrt2 = 141; private const int Weight = 1000; - private readonly List supportActivators = new(); + private readonly List repairActivators = new(); + private readonly List transferActivators = new(); + private readonly List selfCareActivators = new(); private readonly bool canRepair; private readonly bool canTransfer; private readonly double supportRange; @@ -29,6 +31,7 @@ public class SupportAI : BaseAI private PathMovement movement; private PathMovement nextMovement; private CancellationTokenSource source; + private volatile bool pathPending; public SupportAI(SmartCreature smartCreature) : base(smartCreature) { @@ -38,26 +41,37 @@ public SupportAI(SmartCreature smartCreature) : base(smartCreature) List transferers = smartCreature.ActiveModules .OfType() .ToList(); + selfCareActivators.AddRange(smartCreature.ActiveModules + .OfType() + .Select(m => new SupportModuleActivator(m))); canRepair = repairers.Count > 0; canTransfer = transferers.Count > 0; foreach (RemoteArmorRepairModule m in repairers) { - supportActivators.Add(new SupportModuleActivator(m)); + repairActivators.Add(new SupportModuleActivator(m)); } foreach (EnergyTransfererModule m in transferers) { - supportActivators.Add(new SupportModuleActivator(m)); + transferActivators.Add(new SupportModuleActivator(m)); } // Use the smallest module range as the engagement range so we know we're in // range of every equipped support module before we stop and fire. double minRange = double.MaxValue; - foreach (SupportModuleActivator activator in supportActivators) + foreach (SupportModuleActivator a in repairActivators) { - double r = activator.Module.OptimalRange + activator.Module.Falloff; + double r = a.Module.OptimalRange + a.Module.Falloff; + if (r < minRange) + { + minRange = r; + } + } + foreach (SupportModuleActivator a in transferActivators) + { + double r = a.Module.OptimalRange + a.Module.Falloff; if (r < minRange) { minRange = r; @@ -70,7 +84,7 @@ public SupportAI(SmartCreature smartCreature) : base(smartCreature) public override void Enter() { // Drop any combat locks that AggressorAI/IdleAI may have acquired so the - // support module activators only ever see friendly locks. AggressorAI + // support module activators only ever see the friendly lock. AggressorAI // re-acquires combat locks on resume via CombatAI.UpdateHostiles. smartCreature.ResetLocks(); base.Enter(); @@ -91,7 +105,7 @@ public override void Update(TimeSpan time) return; } - if (supportActivators.Count == 0) + if (repairActivators.Count == 0 && transferActivators.Count == 0) { _ = smartCreature.AI.Pop(); @@ -118,6 +132,8 @@ public override void Update(TimeSpan time) smartCreature.ResetLocks(); currentTarget = null; } + + RunModules(time); } // SupportAI shouldn't auto-promote sideways into combat or homing — those @@ -143,6 +159,8 @@ private SmartCreature SelectSupportTarget() } } + need *= 1.1; // Repair is slightly more important than transfer, all else equal. + if (canTransfer) { double c = candidate.CorePercentage; @@ -189,63 +207,108 @@ private void EnsureLock(SmartCreature target) currentTarget = target; } + // Only tick the activators that match what the current target actually needs. + // This avoids wasting energy on a transfer when the ally's core is full, and + // vice versa. private void RunSupportModules(TimeSpan time) { - UnitLock supportLock = currentTarget != null ? smartCreature.GetLockByUnit(currentTarget) : null; + foreach (SupportModuleActivator a in selfCareActivators) + { + a.Update(time, null); + } - foreach (SupportModuleActivator activator in supportActivators) + if (currentTarget == null) { - activator.Update(time, supportLock); + return; } - } - private void UpdateMovement(SmartCreature target, TimeSpan time) - { - bool inRange = smartCreature.IsInRangeOf3D(target, supportRange * 0.9); - _ = repathTimer.Update(time); + UnitLock supportLock = smartCreature.GetLockByUnit(currentTarget); - if (inRange) + if (canRepair && currentTarget.ArmorPercentage < SupportThreshold) { - if (movement != null) + foreach (SupportModuleActivator a in repairActivators) { - smartCreature.StopMoving(); - movement = null; + a.Update(time, supportLock); } + } - return; + if (canTransfer && currentTarget.CorePercentage < SupportThreshold) + { + foreach (SupportModuleActivator a in transferActivators) + { + a.Update(time, supportLock); + } } + } + private void UpdateMovement(SmartCreature target, TimeSpan time) + { + _ = repathTimer.Update(time); + + bool inRange = smartCreature.IsInRangeOf3D(target, supportRange * 0.9); bool targetMoved = !target.CurrentPosition.IsEqual2D(lastTargetPosition); + bool forceRepath = repathTimer.Passed; - if (movement == null || targetMoved || repathTimer.Passed) + if (forceRepath) { repathTimer.Reset(); + } + + // Mirror CombatAI's trigger: re-path only when the target moved or the + // periodic timer fires. Using `movement == null` here would re-issue the + // path search every tick until the first path comes back, cancelling each + // worker before it can finish. + if (!inRange && !pathPending && (targetMoved || forceRepath || movement == null)) + { lastTargetPosition = target.CurrentPosition; + pathPending = true; _ = FindNewSupportPositionAsync(target).ContinueWith(t => { - if (t.IsCanceled) + try { - return; + if (t.IsCanceled || t.IsFaulted) + { + return; + } + + List path = t.Result; + if (path == null) + { + return; + } + + _ = Interlocked.Exchange(ref nextMovement, new PathMovement(path)); } - - List path = t.Result; - if (path == null) + finally { - return; + pathPending = false; } - - _ = Interlocked.Exchange(ref nextMovement, new PathMovement(path)); }); } if (nextMovement != null) { - movement = Interlocked.Exchange(ref nextMovement, null); - movement.Start(smartCreature); + PathMovement pending = Interlocked.Exchange(ref nextMovement, null); + if (pending != null) + { + movement = pending; + movement.Start(smartCreature); + } + } + + if (inRange && movement != null) + { + smartCreature.StopMoving(); + movement = null; } movement?.Update(smartCreature, time); + + if (movement != null && movement.Arrived) + { + movement = null; + } } private Task> FindNewSupportPositionAsync(Unit target) @@ -257,86 +320,100 @@ private Task> FindNewSupportPositionAsync(Unit target) } // Modeled on CombatAI.FindNewAttackPosition — A* over walkable tiles inside the - // creature's home range, looking for a tile within the smallest equipped support - // module's range with LOS to the target. + // creature's home range, looking for a tile within the smallest equipped + // support module's range with LOS to the target. Runs on a worker thread, so + // it must NOT mutate AI fields like `movement` directly — that race was the + // source of the line-245 NRE. private List FindSupportPosition(Unit target, CancellationToken cancellationToken) { - int approachRange = (int)Math.Max(1, supportRange * 0.7); - Point end = target.CurrentPosition.GetRandomPositionInRange2D(0, approachRange).ToPoint(); - - smartCreature.StopMoving(); - movement = null; - - double maxNode = Math.Pow(smartCreature.HomeRange, 2) * Math.PI; - PriorityQueue priorityQueue = new((int)maxNode); - Node startNode = new(smartCreature.CurrentPosition); - - priorityQueue.Enqueue(startNode); + try + { + int approachRange = (int)Math.Max(1, supportRange * 0.7); + Point end = target.CurrentPosition.GetRandomPositionInRange2D(0, approachRange).ToPoint(); - HashSet closed = - [ - startNode.position - ]; + double maxNode = Math.Pow(smartCreature.HomeRange, 2) * Math.PI; + PriorityQueue priorityQueue = new((int)maxNode); + Node startNode = new(smartCreature.CurrentPosition); - while (priorityQueue.TryDequeue(out Node current)) - { - if (cancellationToken.IsCancellationRequested) - { - return null; - } + priorityQueue.Enqueue(startNode); - if (IsValidSupportPosition(target, current.position)) - { - return BuildPath(current); - } + HashSet closed = + [ + startNode.position + ]; - foreach (Point n in current.position.GetNeighbours()) + while (priorityQueue.TryDequeue(out Node current)) { - if (closed.Contains(n)) + if (cancellationToken.IsCancellationRequested) { - continue; + return null; } - _ = closed.Add(n); - - if (!smartCreature.IsWalkable(n.X, n.Y)) + if (IsValidSupportPosition(target, current.position)) { - continue; + return BuildPath(current); } - if (!n.IsInRange(smartCreature.HomePosition, smartCreature.HomeRange)) + foreach (Point n in current.position.GetNeighbours()) { - continue; + if (closed.Contains(n)) + { + continue; + } + + _ = closed.Add(n); + + if (!smartCreature.IsWalkable(n.X, n.Y)) + { + continue; + } + + if (!n.IsInRange(smartCreature.HomePosition, smartCreature.HomeRange)) + { + continue; + } + + int newG = current.g + (n.X - current.position.X == 0 || n.Y - current.position.Y == 0 ? 100 : Sqrt2); + int newH = Heuristic.Manhattan.Calculate(n.X, n.Y, end.X, end.Y) * Weight; + Node newNode = new(n) + { + g = newG, + f = newG + newH, + parent = current + }; + + priorityQueue.Enqueue(newNode); } - - int newG = current.g + (n.X - current.position.X == 0 || n.Y - current.position.Y == 0 ? 100 : Sqrt2); - int newH = Heuristic.Manhattan.Calculate(n.X, n.Y, end.X, end.Y) * Weight; - Node newNode = new(n) - { - g = newG, - f = newG + newH, - parent = current - }; - - priorityQueue.Enqueue(newNode); } - } - return null; + return null; + } + catch + { + // Worker exceptions (e.g. zone teardown mid-search) must not fault the + // task — the AI tick will simply re-issue the request next cycle. + return null; + } } private bool IsValidSupportPosition(Unit target, Point position) { - Position position3 = smartCreature.Zone.FixZ(position.ToPosition()).AddToZ(smartCreature.Height); + IZone zone = smartCreature.Zone; + if (zone == null) + { + return false; + } + + Position position3 = zone.FixZ(position.ToPosition()).AddToZ(smartCreature.Height); if (!target.CurrentPosition.IsInRangeOf3D(position3, supportRange * 0.9)) { return false; } - LOSResult r = smartCreature.Zone.IsInLineOfSight(position3, target, false); + LOSResult r = zone.IsInLineOfSight(position3, target, false); - return !r.hit; + return r != null && !r.hit; } private static List BuildPath(Node current) diff --git a/src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs b/src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs index dcb6625..f3655e0 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs @@ -1,9 +1,8 @@ using Perpetuum.Modules; +using Perpetuum.Modules.EffectModules; using Perpetuum.Timers; using Perpetuum.Zones.Locking; using Perpetuum.Zones.Locking.Locks; -using Perpetuum.Zones.Terrains; -using System; namespace Perpetuum.Zones.NpcSystem.AI { @@ -35,6 +34,13 @@ public void Update(TimeSpan time, UnitLock supportLock) return; } + if (module is ShieldGeneratorModule) + { + module.State.SwitchTo(ModuleStateType.AutoRepeat); + + return; + } + if (supportLock == null || supportLock.State != LockState.Locked) { return; diff --git a/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs b/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs index e7e75ef..0c01e14 100644 --- a/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs +++ b/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs @@ -2,6 +2,7 @@ using Perpetuum.ExportedTypes; using Perpetuum.Items; using Perpetuum.Modules; +using Perpetuum.Modules.EffectModules; using Perpetuum.Players; using Perpetuum.StateMachines; using Perpetuum.Timers; @@ -78,6 +79,12 @@ public virtual bool ShouldFlee() return false; } + if (!ActiveModules.OfType().Any() && + !ActiveModules.OfType().Any()) + { + return false; + } + if (ArmorPercentage < FleeArmorThreshold) { return true;