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/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/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/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 new file mode 100644 index 0000000..c3e5b70 --- /dev/null +++ b/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs @@ -0,0 +1,433 @@ +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 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 repairActivators = new(); + private readonly List transferActivators = new(); + private readonly List selfCareActivators = 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; + private volatile bool pathPending; + + public SupportAI(SmartCreature smartCreature) : base(smartCreature) + { + List repairers = smartCreature.ActiveModules + .OfType() + .ToList(); + 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) + { + repairActivators.Add(new SupportModuleActivator(m)); + } + + foreach (EnergyTransfererModule m in transferers) + { + 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 a in repairActivators) + { + 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; + } + } + + 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 the friendly lock. 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 (repairActivators.Count == 0 && transferActivators.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; + } + + RunModules(time); + } + + // 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); + } + } + + need *= 1.1; // Repair is slightly more important than transfer, all else equal. + + 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; + } + + // 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) + { + foreach (SupportModuleActivator a in selfCareActivators) + { + a.Update(time, null); + } + + if (currentTarget == null) + { + return; + } + + UnitLock supportLock = smartCreature.GetLockByUnit(currentTarget); + + if (canRepair && currentTarget.ArmorPercentage < SupportThreshold) + { + foreach (SupportModuleActivator a in repairActivators) + { + a.Update(time, supportLock); + } + } + + 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 (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 => + { + try + { + if (t.IsCanceled || t.IsFaulted) + { + return; + } + + List path = t.Result; + if (path == null) + { + return; + } + + _ = Interlocked.Exchange(ref nextMovement, new PathMovement(path)); + } + finally + { + pathPending = false; + } + }); + } + + if (nextMovement != null) + { + 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) + { + 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. 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) + { + try + { + int approachRange = (int)Math.Max(1, supportRange * 0.7); + Point end = target.CurrentPosition.GetRandomPositionInRange2D(0, approachRange).ToPoint(); + + 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; + } + 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) + { + 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 = zone.IsInLineOfSight(position3, target, false); + + return r != null && !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..f3655e0 --- /dev/null +++ b/src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs @@ -0,0 +1,62 @@ +using Perpetuum.Modules; +using Perpetuum.Modules.EffectModules; +using Perpetuum.Timers; +using Perpetuum.Zones.Locking; +using Perpetuum.Zones.Locking.Locks; + +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 (module is ShieldGeneratorModule) + { + module.State.SwitchTo(ModuleStateType.AutoRepeat); + + 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..0c01e14 100644 --- a/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs +++ b/src/Perpetuum/Zones/NpcSystem/SmartCreature.cs @@ -1,6 +1,8 @@ using Perpetuum.EntityFramework; using Perpetuum.ExportedTypes; using Perpetuum.Items; +using Perpetuum.Modules; +using Perpetuum.Modules.EffectModules; using Perpetuum.Players; using Perpetuum.StateMachines; using Perpetuum.Timers; @@ -77,6 +79,12 @@ public virtual bool ShouldFlee() return false; } + if (!ActiveModules.OfType().Any() && + !ActiveModules.OfType().Any()) + { + return false; + } + if (ArmorPercentage < FleeArmorThreshold) { return true; @@ -400,6 +408,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(); 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(); }