]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
NPC utility queries (#15843)
authormetalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Mon, 1 May 2023 18:57:11 +0000 (04:57 +1000)
committerGitHub <noreply@github.com>
Mon, 1 May 2023 18:57:11 +0000 (14:57 -0400)
50 files changed:
Content.Client/Entry/EntryPoint.cs
Content.Client/NPC/HTN/HTNOverlay.cs
Content.Server/NPC/HTN/HTNComponent.cs
Content.Server/NPC/HTN/HTNSystem.cs
Content.Server/NPC/HTN/PrimitiveTasks/HTNPrimitiveTask.cs
Content.Server/NPC/HTN/PrimitiveTasks/Operators/AltInteractOperator.cs [new file with mode: 0644]
Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/MeleeOperator.cs
Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/PickMeleeTargetOperator.cs [deleted file]
Content.Server/NPC/HTN/PrimitiveTasks/Operators/NPCCombatOperator.cs [deleted file]
Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/PickRangedTargetOperator.cs [deleted file]
Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/RangedOperator.cs
Content.Server/NPC/HTN/PrimitiveTasks/Operators/UtilityOperator.cs [new file with mode: 0644]
Content.Server/NPC/NPCBlackboard.cs
Content.Server/NPC/Queries/Considerations/FoodValueCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/TargetAccessibleCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/TargetDistanceCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/TargetHealthCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/TargetInLOSCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/TargetInLOSOrCurrentCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/TargetIsAliveCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/TargetIsCritCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/TargetIsDeadCon.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Considerations/UtilityConsideration.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Curves/BoolCurve.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Curves/IUtilityCurve.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Curves/InverseBoolCurve.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Curves/PresetCurve.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Curves/QuadraticCurve.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Curves/UtilityCurvePresetPrototype.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Queries/ClothingSlotFilter.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Queries/ComponentQuery.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Queries/NearbyComponentsQuery.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Queries/NearbyHostilesQuery.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Queries/PuddlesQuery.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Queries/UtilityQuery.cs [new file with mode: 0644]
Content.Server/NPC/Queries/Queries/UtilityQueryFilter.cs [new file with mode: 0644]
Content.Server/NPC/Queries/UtilityQueryPrototype.cs [new file with mode: 0644]
Content.Server/NPC/Queries/UtilityService.cs [new file with mode: 0644]
Content.Server/NPC/Systems/NPCCombatSystem.Melee.cs
Content.Server/NPC/Systems/NPCSteeringSystem.Context.cs
Content.Server/NPC/Systems/NPCSteeringSystem.cs
Content.Server/NPC/Systems/NPCUtilitySystem.cs [new file with mode: 0644]
Content.Server/Nutrition/EntitySystems/FoodSystem.cs
Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
Resources/Prototypes/NPCs/attack.yml
Resources/Prototypes/NPCs/mob.yml
Resources/Prototypes/NPCs/nutrition.yml [new file with mode: 0644]
Resources/Prototypes/NPCs/utility_queries.yml [new file with mode: 0644]
Resources/Prototypes/ai_factions.yml

index 1d30169019caf46a73ad8e64c0b2d9f8025bb921..c6b9b13877cdd6e18be69b4a01ac01f144ce24d4 100644 (file)
@@ -88,6 +88,8 @@ namespace Content.Client.Entry
             _componentFactory.RegisterClass<SharedAMEControllerComponent>();
             // Do not add to the above, they are legacy
 
+            _prototypeManager.RegisterIgnore("utilityQuery");
+            _prototypeManager.RegisterIgnore("utilityCurvePreset");
             _prototypeManager.RegisterIgnore("accent");
             _prototypeManager.RegisterIgnore("material");
             _prototypeManager.RegisterIgnore("reaction"); //Chemical reactions only needed by server. Reactions checks are server-side.
index 30234cb31a7db5d2288fdc72c626a596be6bf09d..0d1a4f8c8a9b62cc6e0f233dc34c77f68b471aed 100644 (file)
@@ -35,7 +35,7 @@ public sealed class HTNOverlay : Overlay
                 continue;
 
             var screenPos = args.ViewportControl.WorldToScreen(worldPos);
-            handle.DrawString(_font, screenPos + new Vector2(0, 10f), comp.DebugText);
+            handle.DrawString(_font, screenPos + new Vector2(0, 10f), comp.DebugText, Color.White);
         }
     }
 }
index 4fa5f216bea1d87ce6865c9433af5593baae137d..aabfe99ad258e14948bc30cdf3ac2fdc9de35202 100644 (file)
@@ -20,6 +20,13 @@ public sealed class HTNComponent : NPCComponent
     [ViewVariables]
     public HTNPlan? Plan;
 
+    // TODO: Need dictionary timeoffsetserializer.
+    /// <summary>
+    /// Last time we tried a particular <see cref="UtilityService"/>.
+    /// </summary>
+    [DataField("serviceCooldowns")]
+    public Dictionary<string, TimeSpan> ServiceCooldowns = new();
+
     /// <summary>
     /// How long to wait after having planned to try planning again.
     /// </summary>
@@ -42,6 +49,4 @@ public sealed class HTNComponent : NPCComponent
     /// Is this NPC currently planning?
     /// </summary>
     [ViewVariables] public bool Planning => PlanningJob != null;
-
-
 }
index 8ed8708c1f609978aeb1f64cf39e7f48d0c7117b..9beabeef7acecc3ad6f8703fbdbc2ff5410d0d75 100644 (file)
@@ -13,17 +13,22 @@ using JetBrains.Annotations;
 using Robust.Server.Player;
 using Robust.Shared.Players;
 using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
 
 namespace Content.Server.NPC.HTN;
 
 public sealed class HTNSystem : EntitySystem
 {
     [Dependency] private readonly IAdminManager _admin = default!;
+    [Dependency] private readonly IGameTiming _timing = default!;
     [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+    [Dependency] private readonly IRobustRandom _random = default!;
     [Dependency] private readonly NPCSystem _npc = default!;
+    [Dependency] private readonly NPCUtilitySystem _utility = default!;
 
     private ISawmill _sawmill = default!;
-    private readonly JobQueue _planQueue = new();
+    private readonly JobQueue _planQueue = new(0.004);
 
     private readonly HashSet<ICommonSession> _subscribers = new();
 
@@ -37,12 +42,22 @@ public sealed class HTNSystem : EntitySystem
         base.Initialize();
         _sawmill = Logger.GetSawmill("npc.htn");
         SubscribeLocalEvent<HTNComponent, ComponentShutdown>(OnHTNShutdown);
+        SubscribeLocalEvent<HTNComponent, EntityUnpausedEvent>(OnHTNUnpaused);
         SubscribeNetworkEvent<RequestHTNMessage>(OnHTNMessage);
 
         _prototypeManager.PrototypesReloaded += OnPrototypeLoad;
         OnLoad();
     }
 
+    private void OnHTNUnpaused(EntityUid uid, HTNComponent component, ref EntityUnpausedEvent args)
+    {
+        foreach (var (service, cooldown) in component.ServiceCooldowns)
+        {
+            var newCooldown = cooldown + args.PausedTime;
+            component.ServiceCooldowns[service] = newCooldown;
+        }
+    }
+
     private void OnHTNMessage(RequestHTNMessage msg, EntitySessionEventArgs args)
     {
         if (!_admin.HasAdminFlag((IPlayerSession) args.SenderSession, AdminFlags.Debug))
@@ -251,7 +266,7 @@ public sealed class HTNSystem : EntitySystem
         // If it's the selected BTR then highlight.
         for (var i = 0; i < btr.Count; i++)
         {
-            text.Append('-');
+            text.Append("--");
         }
 
         text.Append(' ');
@@ -272,7 +287,7 @@ public sealed class HTNSystem : EntitySystem
             {
                 var branch = branches[i];
                 btr.Add(i);
-                text.AppendLine($" branch {string.Join(" ", btr)}:");
+                text.AppendLine($" branch {string.Join(", ", btr)}:");
 
                 foreach (var sub in branch)
                 {
@@ -313,7 +328,25 @@ public sealed class HTNSystem : EntitySystem
         {
             // Run the existing operator
             var currentOperator = component.Plan.CurrentOperator;
+            var currentTask = component.Plan.CurrentTask;
             var blackboard = component.Blackboard;
+
+            foreach (var service in currentTask.Services)
+            {
+                // Service still on cooldown.
+                if (component.ServiceCooldowns.TryGetValue(service.ID, out var lastService) &&
+                    _timing.CurTime < lastService)
+                {
+                    continue;
+                }
+
+                var serviceResult = _utility.GetEntities(blackboard, service.Prototype);
+                blackboard.SetValue(service.Key, serviceResult.GetHighest());
+
+                var cooldown = TimeSpan.FromSeconds(_random.NextFloat(service.MinCooldown, service.MaxCooldown));
+                component.ServiceCooldowns[service.ID] = _timing.CurTime + cooldown;
+            }
+
             status = currentOperator.Update(blackboard, frameTime);
 
             switch (status)
@@ -322,6 +355,7 @@ public sealed class HTNSystem : EntitySystem
                     break;
                 case HTNOperatorStatus.Failed:
                     currentOperator.Shutdown(blackboard, status);
+                    component.ServiceCooldowns.Clear();
                     component.Plan = null;
                     break;
                 // Operator completed so go to the next one.
@@ -332,6 +366,7 @@ public sealed class HTNSystem : EntitySystem
                     // Plan finished!
                     if (component.Plan.Tasks.Count <= component.Plan.Index)
                     {
+                        component.ServiceCooldowns.Clear();
                         component.Plan = null;
                         break;
                     }
index f9d671041b702e34bd368afb5aa42d4c100a01ec..2c574ba298d95cba3714cf6030197911394ceb97 100644 (file)
@@ -1,4 +1,5 @@
 using Content.Server.NPC.HTN.Preconditions;
+using Content.Server.NPC.Queries;
 using Robust.Shared.Prototypes;
 
 namespace Content.Server.NPC.HTN.PrimitiveTasks;
@@ -19,4 +20,9 @@ public sealed class HTNPrimitiveTask : HTNTask
     [DataField("preconditions")] public List<HTNPrecondition> Preconditions = new();
 
     [DataField("operator", required:true)] public HTNOperator Operator = default!;
+
+    /// <summary>
+    /// Services actively tick and can potentially update keys, such as combat target.
+    /// </summary>
+    [DataField("services")] public List<UtilityService> Services = new();
 }
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/AltInteractOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/AltInteractOperator.cs
new file mode 100644 (file)
index 0000000..4229886
--- /dev/null
@@ -0,0 +1,57 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Content.Shared.DoAfter;
+using Content.Shared.Interaction;
+
+namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators;
+
+public sealed class AltInteractOperator : HTNOperator
+{
+    [Dependency] private readonly IEntityManager _entManager = default!;
+
+    [DataField("targetKey")]
+    public string Key = "CombatTarget";
+
+    /// <summary>
+    /// If this alt-interaction started a do_after where does the key get stored.
+    /// </summary>
+    [DataField("idleKey")]
+    public string IdleKey = "IdleTime";
+
+    public override async Task<(bool Valid, Dictionary<string, object>? Effects)> Plan(NPCBlackboard blackboard, CancellationToken cancelToken)
+    {
+        return new(true, new Dictionary<string, object>()
+        {
+            { IdleKey, 1f }
+        });
+    }
+
+    public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
+    {
+        var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+        var target = blackboard.GetValue<EntityUid>(Key);
+        var intSystem = _entManager.System<SharedInteractionSystem>();
+        var count = 0;
+
+        if (_entManager.TryGetComponent<DoAfterComponent>(owner, out var doAfter))
+        {
+            count = doAfter.DoAfters.Count;
+        }
+
+        var result = intSystem.AltInteract(owner, target);
+
+        // Interaction started a doafter so set the idle time to it.
+        if (result && doAfter != null && count != doAfter.DoAfters.Count)
+        {
+            var wait = doAfter.DoAfters.First().Value.Args.Delay;
+            blackboard.SetValue(IdleKey, (float) wait.TotalSeconds + 0.5f);
+        }
+        else
+        {
+            blackboard.SetValue(IdleKey, 1f);
+        }
+
+        return result ? HTNOperatorStatus.Finished : HTNOperatorStatus.Failed;
+    }
+}
index 80516d944d232ef28374745fd3fd47fd952b2e7c..8831b8785033dba265e7976583d227707e33b8c4 100644 (file)
@@ -45,7 +45,6 @@ public sealed class MeleeOperator : HTNOperator
         }
 
         if (_entManager.TryGetComponent<MobStateComponent>(target, out var mobState) &&
-            mobState.CurrentState != null &&
             mobState.CurrentState > TargetState)
         {
             return (false, null);
@@ -65,13 +64,15 @@ public sealed class MeleeOperator : HTNOperator
     {
         base.Update(blackboard, frameTime);
         var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
-        var status = HTNOperatorStatus.Continuing;
+        HTNOperatorStatus status;
 
-        if (_entManager.TryGetComponent<NPCMeleeCombatComponent>(owner, out var combat))
+        if (_entManager.TryGetComponent<NPCMeleeCombatComponent>(owner, out var combat) &&
+            blackboard.TryGetValue<EntityUid>(TargetKey, out var target, _entManager))
         {
+            combat.Target = target;
+
             // Success
-            if (_entManager.TryGetComponent<MobStateComponent>(combat.Target, out var mobState) &&
-                mobState.CurrentState != null &&
+            if (_entManager.TryGetComponent<MobStateComponent>(target, out var mobState) &&
                 mobState.CurrentState > TargetState)
             {
                 status = HTNOperatorStatus.Finished;
@@ -90,6 +91,10 @@ public sealed class MeleeOperator : HTNOperator
                 }
             }
         }
+        else
+        {
+            status = HTNOperatorStatus.Failed;
+        }
 
         if (status != HTNOperatorStatus.Continuing)
         {
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/PickMeleeTargetOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/PickMeleeTargetOperator.cs
deleted file mode 100644 (file)
index 5bbfc98..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-using JetBrains.Annotations;
-
-namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Melee;
-
-/// <summary>
-/// Selects a target for melee.
-/// </summary>
-[MeansImplicitUse]
-public sealed class PickMeleeTargetOperator : NPCCombatOperator
-{
-    protected override float GetRating(NPCBlackboard blackboard, EntityUid uid, EntityUid existingTarget, float distance, bool canMove, EntityQuery<TransformComponent> xformQuery)
-    {
-        var rating = 0f;
-
-        if (existingTarget == uid)
-        {
-            rating += 2f;
-        }
-
-        if (distance > 0f)
-            rating += 50f / distance;
-
-        return rating;
-    }
-}
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/NPCCombatOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/NPCCombatOperator.cs
deleted file mode 100644 (file)
index 9b22f70..0000000
+++ /dev/null
@@ -1,170 +0,0 @@
-using System.Threading;
-using System.Threading.Tasks;
-using Content.Server.Interaction;
-using Content.Server.NPC.Components;
-using Content.Server.NPC.Pathfinding;
-using Content.Server.NPC.Systems;
-using Content.Shared.Examine;
-using Content.Shared.Interaction;
-using Content.Shared.Mobs;
-using Content.Shared.Mobs.Components;
-using Robust.Shared.Map;
-//using Robust.Shared.Prototypes;
-
-namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators;
-
-public abstract class NPCCombatOperator : HTNOperator
-{
-    [Dependency] protected readonly IEntityManager EntManager = default!;
-    private FactionSystem _factions = default!;
-    private FactionExceptionSystem _factionException = default!;
-    protected InteractionSystem Interaction = default!;
-    private PathfindingSystem _pathfinding = default!;
-
-    [DataField("key")] public string Key = "CombatTarget";
-
-    /// <summary>
-    /// The EntityCoordinates of the specified target.
-    /// </summary>
-    [DataField("keyCoordinates")]
-    public string KeyCoordinates = "CombatTargetCoordinates";
-
-    /// <summary>
-    /// Regardless of pathfinding or LOS these are the max we'll check
-    /// </summary>
-    private const int MaxConsideredTargets = 10;
-
-    protected virtual bool IsRanged => false;
-
-    public override void Initialize(IEntitySystemManager sysManager)
-    {
-        base.Initialize(sysManager);
-        sysManager.GetEntitySystem<ExamineSystemShared>();
-        _factions = sysManager.GetEntitySystem<FactionSystem>();
-        _factionException = sysManager.GetEntitySystem<FactionExceptionSystem>();
-        Interaction = sysManager.GetEntitySystem<InteractionSystem>();
-        _pathfinding = sysManager.GetEntitySystem<PathfindingSystem>();
-    }
-
-    public override async Task<(bool Valid, Dictionary<string, object>? Effects)> Plan(NPCBlackboard blackboard,
-        CancellationToken cancelToken)
-    {
-        var targets = await GetTargets(blackboard);
-
-        if (targets.Count == 0)
-        {
-            return (false, null);
-        }
-
-        // TODO: Need some level of rng in ratings (outside of continuing to attack the same target)
-        var selectedTarget = targets[0].Entity;
-
-        var effects = new Dictionary<string, object>()
-        {
-            {Key, selectedTarget},
-            {KeyCoordinates, new EntityCoordinates(selectedTarget, Vector2.Zero)}
-        };
-
-        return (true, effects);
-    }
-
-    private async Task<List<(EntityUid Entity, float Rating, float Distance)>> GetTargets(NPCBlackboard blackboard)
-    {
-        var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
-        var ownerCoordinates = blackboard.GetValueOrDefault<EntityCoordinates>(NPCBlackboard.OwnerCoordinates, EntManager);
-        var radius = blackboard.GetValueOrDefault<float>(NPCBlackboard.VisionRadius, EntManager);
-        var targets = new List<(EntityUid Entity, float Rating, float Distance)>();
-
-        blackboard.TryGetValue<EntityUid>(Key, out var existingTarget, EntManager);
-        var xformQuery = EntManager.GetEntityQuery<TransformComponent>();
-        var mobQuery = EntManager.GetEntityQuery<MobStateComponent>();
-        var canMove = blackboard.GetValueOrDefault<bool>(NPCBlackboard.CanMove, EntManager);
-        var count = 0;
-        var paths = new List<Task>();
-        // TODO: Really this should be a part of perception so we don't have to constantly re-plan targets.
-
-        // Special-case existing target.
-        if (EntManager.EntityExists(existingTarget))
-        {
-            paths.Add(UpdateTarget(owner, existingTarget, existingTarget, ownerCoordinates, blackboard, radius, canMove, xformQuery, targets));
-        }
-
-        EntManager.TryGetComponent<FactionExceptionComponent>(owner, out var factionException);
-
-        // TODO: Need a perception system instead
-        // TODO: This will be expensive so will be good to optimise and cut corners.
-        foreach (var target in _factions
-                     .GetNearbyHostiles(owner, radius))
-        {
-            if (mobQuery.TryGetComponent(target, out var mobState) &&
-                mobState.CurrentState > MobState.Alive ||
-                target == existingTarget ||
-                target == owner ||
-                (factionException != null && _factionException.IsIgnored(factionException, target)))
-            {
-                continue;
-            }
-
-            count++;
-
-            if (count >= MaxConsideredTargets)
-                break;
-
-            paths.Add(UpdateTarget(owner, target, existingTarget, ownerCoordinates, blackboard, radius, canMove, xformQuery, targets));
-        }
-
-        await Task.WhenAll(paths);
-
-        targets.Sort((x, y) => y.Rating.CompareTo(x.Rating));
-        return targets;
-    }
-
-    private async Task UpdateTarget(
-        EntityUid owner,
-        EntityUid target,
-        EntityUid existingTarget,
-        EntityCoordinates ownerCoordinates,
-        NPCBlackboard blackboard,
-        float radius,
-        bool canMove,
-        EntityQuery<TransformComponent> xformQuery,
-        List<(EntityUid Entity, float Rating, float Distance)> targets)
-    {
-        if (!xformQuery.TryGetComponent(target, out var targetXform))
-            return;
-
-        var inLos = false;
-
-        // If it's not an existing target then check LOS.
-        if (target != existingTarget)
-        {
-            inLos = ExamineSystemShared.InRangeUnOccluded(owner, target, radius, null);
-
-            if (!inLos)
-                return;
-        }
-
-        // Turret or the likes, check LOS only.
-        if (IsRanged && !canMove)
-        {
-            inLos = inLos || ExamineSystemShared.InRangeUnOccluded(owner, target, radius, null);
-
-            if (!inLos || !targetXform.Coordinates.TryDistance(EntManager, ownerCoordinates, out var distance))
-                return;
-
-            targets.Add((target, GetRating(blackboard, target, existingTarget, distance, canMove, xformQuery), distance));
-            return;
-        }
-
-        var nDistance = await _pathfinding.GetPathDistance(owner, targetXform.Coordinates,
-            SharedInteractionSystem.InteractionRange, default, _pathfinding.GetFlags(blackboard));
-
-        if (nDistance == null)
-            return;
-
-        targets.Add((target, GetRating(blackboard, target, existingTarget, nDistance.Value, canMove, xformQuery), nDistance.Value));
-    }
-
-    protected abstract float GetRating(NPCBlackboard blackboard, EntityUid uid, EntityUid existingTarget, float distance, bool canMove,
-        EntityQuery<TransformComponent> xformQuery);
-}
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/PickRangedTargetOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/PickRangedTargetOperator.cs
deleted file mode 100644 (file)
index f1f1244..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-using JetBrains.Annotations;
-
-namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Ranged;
-
-/// <summary>
-/// Selects a target for ranged combat.
-/// </summary>
-[UsedImplicitly]
-public sealed class PickRangedTargetOperator : NPCCombatOperator
-{
-    protected override bool IsRanged => true;
-
-    protected override float GetRating(NPCBlackboard blackboard, EntityUid uid, EntityUid existingTarget, float distance, bool canMove, EntityQuery<TransformComponent> xformQuery)
-    {
-        // Yeah look I just came up with values that seemed okay but they will need a lot of tweaking.
-        // Having a debug overlay just to project these would be very useful when finetuning in future.
-        var rating = 0f;
-
-        if (existingTarget == uid)
-        {
-            rating += 2f;
-        }
-
-        rating += 1f / distance * 4f;
-        return rating;
-    }
-}
index c4e2fb3afdfacb400c509a5079293f7a097c8cd0..3446cd964a54b869599bf8874da3c423890ac212 100644 (file)
@@ -35,7 +35,6 @@ public sealed class RangedOperator : HTNOperator
         }
 
         if (_entManager.TryGetComponent<MobStateComponent>(target, out var mobState) &&
-            mobState.CurrentState != null &&
             mobState.CurrentState > TargetState)
         {
             return (false, null);
@@ -72,13 +71,15 @@ public sealed class RangedOperator : HTNOperator
     {
         base.Update(blackboard, frameTime);
         var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
-        var status = HTNOperatorStatus.Continuing;
+        HTNOperatorStatus status;
 
-        if (_entManager.TryGetComponent<NPCRangedCombatComponent>(owner, out var combat))
+        if (_entManager.TryGetComponent<NPCRangedCombatComponent>(owner, out var combat) &&
+            blackboard.TryGetValue<EntityUid>(TargetKey, out var target, _entManager))
         {
+            combat.Target = target;
+
             // Success
             if (_entManager.TryGetComponent<MobStateComponent>(combat.Target, out var mobState) &&
-                mobState.CurrentState != null &&
                 mobState.CurrentState > TargetState)
             {
                 status = HTNOperatorStatus.Finished;
@@ -100,6 +101,10 @@ public sealed class RangedOperator : HTNOperator
                 }
             }
         }
+        else
+        {
+            status = HTNOperatorStatus.Failed;
+        }
 
         if (status != HTNOperatorStatus.Continuing)
         {
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/UtilityOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/UtilityOperator.cs
new file mode 100644 (file)
index 0000000..523b24d
--- /dev/null
@@ -0,0 +1,47 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Content.Server.NPC.Queries;
+using Content.Server.NPC.Systems;
+using Robust.Shared.Map;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators;
+
+/// <summary>
+/// Utilises a <see cref="UtilityQueryPrototype"/> to determine the best target and sets it to the Key.
+/// </summary>
+public sealed class UtilityOperator : HTNOperator
+{
+    [Dependency] private readonly IEntityManager _entManager = default!;
+
+    [DataField("key")] public string Key = "CombatTarget";
+
+    /// <summary>
+    /// The EntityCoordinates of the specified target.
+    /// </summary>
+    [DataField("keyCoordinates")]
+    public string KeyCoordinates = "CombatTargetCoordinates";
+
+    [DataField("proto", required: true, customTypeSerializer:typeof(PrototypeIdSerializer<UtilityQueryPrototype>))]
+    public string Prototype = string.Empty;
+
+    public override async Task<(bool Valid, Dictionary<string, object>? Effects)> Plan(NPCBlackboard blackboard,
+        CancellationToken cancelToken)
+    {
+        var result = _entManager.System<NPCUtilitySystem>().GetEntities(blackboard, Prototype);
+        var target = result.GetHighest();
+
+        if (!target.IsValid())
+        {
+            return (false, new Dictionary<string, object>());
+        }
+
+        var effects = new Dictionary<string, object>()
+        {
+            {Key, target},
+            {KeyCoordinates, new EntityCoordinates(target, Vector2.Zero)}
+        };
+
+        return (true, effects);
+    }
+}
index 0e12827cd71fdb74e6b1b0fbd188b32d5b7427bb..4e8581efb228866e033a98a985138e11d2466fdf 100644 (file)
@@ -27,9 +27,9 @@ public sealed class NPCBlackboard : IEnumerable<KeyValuePair<string, object>>
         {"MeleeRange", 1f},
         {"MinimumIdleTime", 2f},
         {"MovementRange", 1.5f},
-        {"RangedRange", 7f},
+        {"RangedRange", 10f},
         {"RotateSpeed", MathF.PI},
-        {"VisionRadius", 7f},
+        {"VisionRadius", 10f},
     };
 
     /// <summary>
@@ -228,6 +228,7 @@ public sealed class NPCBlackboard : IEnumerable<KeyValuePair<string, object>>
 
     public const string RotateSpeed = "RotateSpeed";
     public const string VisionRadius = "VisionRadius";
+    public const string UtilityTarget = "Target";
 
     public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
     {
diff --git a/Content.Server/NPC/Queries/Considerations/FoodValueCon.cs b/Content.Server/NPC/Queries/Considerations/FoodValueCon.cs
new file mode 100644 (file)
index 0000000..c10213f
--- /dev/null
@@ -0,0 +1,6 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+public sealed class FoodValueCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/TargetAccessibleCon.cs b/Content.Server/NPC/Queries/Considerations/TargetAccessibleCon.cs
new file mode 100644 (file)
index 0000000..d47f741
--- /dev/null
@@ -0,0 +1,9 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns 1f if the target is freely accessible (e.g. not in locked storage).
+/// </summary>
+public sealed class TargetAccessibleCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/TargetDistanceCon.cs b/Content.Server/NPC/Queries/Considerations/TargetDistanceCon.cs
new file mode 100644 (file)
index 0000000..a2cb99b
--- /dev/null
@@ -0,0 +1,6 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+public sealed class TargetDistanceCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/TargetHealthCon.cs b/Content.Server/NPC/Queries/Considerations/TargetHealthCon.cs
new file mode 100644 (file)
index 0000000..a649667
--- /dev/null
@@ -0,0 +1,6 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+public sealed class TargetHealthCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/TargetInLOSCon.cs b/Content.Server/NPC/Queries/Considerations/TargetInLOSCon.cs
new file mode 100644 (file)
index 0000000..219a301
--- /dev/null
@@ -0,0 +1,9 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns whether the target is in line-of-sight.
+/// </summary>
+public sealed class TargetInLOSCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/TargetInLOSOrCurrentCon.cs b/Content.Server/NPC/Queries/Considerations/TargetInLOSOrCurrentCon.cs
new file mode 100644 (file)
index 0000000..2919a91
--- /dev/null
@@ -0,0 +1,9 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Placeholder considerations -> returns 1f if they're in LOS or the current target.
+/// </summary>
+public sealed class TargetInLOSOrCurrentCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/TargetIsAliveCon.cs b/Content.Server/NPC/Queries/Considerations/TargetIsAliveCon.cs
new file mode 100644 (file)
index 0000000..3c74811
--- /dev/null
@@ -0,0 +1,9 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns 1f if the target is alive or 0f if not.
+/// </summary>
+public sealed class TargetIsAliveCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/TargetIsCritCon.cs b/Content.Server/NPC/Queries/Considerations/TargetIsCritCon.cs
new file mode 100644 (file)
index 0000000..03c73a1
--- /dev/null
@@ -0,0 +1,9 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns 1f if the target is crit or 0f if not.
+/// </summary>
+public sealed class TargetIsCritCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/TargetIsDeadCon.cs b/Content.Server/NPC/Queries/Considerations/TargetIsDeadCon.cs
new file mode 100644 (file)
index 0000000..53e0a9b
--- /dev/null
@@ -0,0 +1,9 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns 1f if the target is dead or 0f if not.
+/// </summary>
+public sealed class TargetIsDeadCon : UtilityConsideration
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Considerations/UtilityConsideration.cs b/Content.Server/NPC/Queries/Considerations/UtilityConsideration.cs
new file mode 100644 (file)
index 0000000..e74b7aa
--- /dev/null
@@ -0,0 +1,11 @@
+using Content.Server.NPC.Queries.Curves;
+using JetBrains.Annotations;
+
+namespace Content.Server.NPC.Queries.Considerations;
+
+[ImplicitDataDefinitionForInheritors, MeansImplicitUse]
+public abstract class UtilityConsideration
+{
+    [DataField("curve", required: true)]
+    public IUtilityCurve Curve = default!;
+}
diff --git a/Content.Server/NPC/Queries/Curves/BoolCurve.cs b/Content.Server/NPC/Queries/Curves/BoolCurve.cs
new file mode 100644 (file)
index 0000000..7065116
--- /dev/null
@@ -0,0 +1,5 @@
+namespace Content.Server.NPC.Queries.Curves;
+
+public sealed class BoolCurve : IUtilityCurve
+{
+}
diff --git a/Content.Server/NPC/Queries/Curves/IUtilityCurve.cs b/Content.Server/NPC/Queries/Curves/IUtilityCurve.cs
new file mode 100644 (file)
index 0000000..db48f32
--- /dev/null
@@ -0,0 +1,7 @@
+namespace Content.Server.NPC.Queries.Curves;
+
+[ImplicitDataDefinitionForInheritors]
+public interface IUtilityCurve
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Curves/InverseBoolCurve.cs b/Content.Server/NPC/Queries/Curves/InverseBoolCurve.cs
new file mode 100644 (file)
index 0000000..13b3177
--- /dev/null
@@ -0,0 +1,6 @@
+namespace Content.Server.NPC.Queries.Curves;
+
+public sealed class InverseBoolCurve : IUtilityCurve
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Curves/PresetCurve.cs b/Content.Server/NPC/Queries/Curves/PresetCurve.cs
new file mode 100644 (file)
index 0000000..689be8e
--- /dev/null
@@ -0,0 +1,8 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.NPC.Queries.Curves;
+
+public sealed class PresetCurve : IUtilityCurve
+{
+    [DataField("preset", required: true, customTypeSerializer:typeof(PrototypeIdSerializer<UtilityCurvePresetPrototype>))] public readonly string Preset = default!;
+}
diff --git a/Content.Server/NPC/Queries/Curves/QuadraticCurve.cs b/Content.Server/NPC/Queries/Curves/QuadraticCurve.cs
new file mode 100644 (file)
index 0000000..3dead77
--- /dev/null
@@ -0,0 +1,12 @@
+namespace Content.Server.NPC.Queries.Curves;
+
+public sealed class QuadraticCurve : IUtilityCurve
+{
+    [DataField("slope")] public readonly float Slope;
+
+    [DataField("exponent")] public readonly float Exponent;
+
+    [DataField("yOffset")] public readonly float YOffset;
+
+    [DataField("xOffset")] public readonly float XOffset;
+}
diff --git a/Content.Server/NPC/Queries/Curves/UtilityCurvePresetPrototype.cs b/Content.Server/NPC/Queries/Curves/UtilityCurvePresetPrototype.cs
new file mode 100644 (file)
index 0000000..5ac127c
--- /dev/null
@@ -0,0 +1,11 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.NPC.Queries.Curves;
+
+[Prototype("utilityCurvePreset")]
+public sealed class UtilityCurvePresetPrototype : IPrototype
+{
+    [IdDataField] public string ID { get; } = string.Empty;
+
+    [DataField("curve", required: true)] public IUtilityCurve Curve = default!;
+}
diff --git a/Content.Server/NPC/Queries/Queries/ClothingSlotFilter.cs b/Content.Server/NPC/Queries/Queries/ClothingSlotFilter.cs
new file mode 100644 (file)
index 0000000..2ca04e1
--- /dev/null
@@ -0,0 +1,6 @@
+namespace Content.Server.NPC.Queries.Queries;
+
+public sealed class ClothingSlotFilter : UtilityQueryFilter
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Queries/ComponentQuery.cs b/Content.Server/NPC/Queries/Queries/ComponentQuery.cs
new file mode 100644 (file)
index 0000000..9e101ec
--- /dev/null
@@ -0,0 +1,12 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.NPC.Queries.Queries;
+
+/// <summary>
+/// Returns nearby components that match the specified components.
+/// </summary>
+public sealed class ComponentQuery : UtilityQuery
+{
+    [DataField("components", required: true)]
+    public EntityPrototype.ComponentRegistry Components = default!;
+}
diff --git a/Content.Server/NPC/Queries/Queries/NearbyComponentsQuery.cs b/Content.Server/NPC/Queries/Queries/NearbyComponentsQuery.cs
new file mode 100644 (file)
index 0000000..7741c6b
--- /dev/null
@@ -0,0 +1,9 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.NPC.Queries.Queries;
+
+public sealed class NearbyComponentsQuery : UtilityQuery
+{
+    [DataField("components")]
+    public EntityPrototype.ComponentRegistry Component = default!;
+}
diff --git a/Content.Server/NPC/Queries/Queries/NearbyHostilesQuery.cs b/Content.Server/NPC/Queries/Queries/NearbyHostilesQuery.cs
new file mode 100644 (file)
index 0000000..f3b1518
--- /dev/null
@@ -0,0 +1,9 @@
+namespace Content.Server.NPC.Queries.Queries;
+
+/// <summary>
+/// Returns nearby entities considered hostile from <see cref="FactionSystem"/>
+/// </summary>
+public sealed class NearbyHostilesQuery : UtilityQuery
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Queries/PuddlesQuery.cs b/Content.Server/NPC/Queries/Queries/PuddlesQuery.cs
new file mode 100644 (file)
index 0000000..791e5be
--- /dev/null
@@ -0,0 +1,6 @@
+namespace Content.Server.NPC.Queries.Queries;
+
+public sealed class PuddlesQuery : UtilityQuery
+{
+
+}
diff --git a/Content.Server/NPC/Queries/Queries/UtilityQuery.cs b/Content.Server/NPC/Queries/Queries/UtilityQuery.cs
new file mode 100644 (file)
index 0000000..401d41c
--- /dev/null
@@ -0,0 +1,10 @@
+namespace Content.Server.NPC.Queries.Queries;
+
+/// <summary>
+/// Adds entities to a query.
+/// </summary>
+[ImplicitDataDefinitionForInheritors]
+public abstract class UtilityQuery
+{
+
+}
\ No newline at end of file
diff --git a/Content.Server/NPC/Queries/Queries/UtilityQueryFilter.cs b/Content.Server/NPC/Queries/Queries/UtilityQueryFilter.cs
new file mode 100644 (file)
index 0000000..906cf23
--- /dev/null
@@ -0,0 +1,9 @@
+namespace Content.Server.NPC.Queries.Queries;
+
+/// <summary>
+/// Removes entities from a query.
+/// </summary>
+public abstract class UtilityQueryFilter : UtilityQuery
+{
+
+}
\ No newline at end of file
diff --git a/Content.Server/NPC/Queries/UtilityQueryPrototype.cs b/Content.Server/NPC/Queries/UtilityQueryPrototype.cs
new file mode 100644 (file)
index 0000000..934ac89
--- /dev/null
@@ -0,0 +1,29 @@
+using Content.Server.NPC.Queries.Considerations;
+using Content.Server.NPC.Queries.Queries;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.NPC.Queries;
+
+/// <summary>
+/// Stores data for generic queries.
+/// Each query is run in turn to get the final available results.
+/// These results are then run through the considerations.
+/// </summary>
+[Prototype("utilityQuery")]
+public sealed class UtilityQueryPrototype : IPrototype
+{
+    [IdDataField]
+    public string ID { get; } = default!;
+
+    [ViewVariables(VVAccess.ReadWrite), DataField("query")]
+    public List<UtilityQuery> Query = new();
+
+    [ViewVariables(VVAccess.ReadWrite), DataField("considerations")]
+    public List<UtilityConsideration> Considerations = new();
+
+    /// <summary>
+    /// How many entities we are allowed to consider. This is applied after all queries have run.
+    /// </summary>
+    [DataField("limit")]
+    public int Limit = 128;
+}
diff --git a/Content.Server/NPC/Queries/UtilityService.cs b/Content.Server/NPC/Queries/UtilityService.cs
new file mode 100644 (file)
index 0000000..d8b0a34
--- /dev/null
@@ -0,0 +1,34 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.NPC.Queries;
+
+/// <summary>
+/// Utility queries that run regularly to update an NPC without re-doing their thinking logic.
+/// </summary>
+[DataDefinition]
+public sealed class UtilityService
+{
+    /// <summary>
+    /// Identifier to use for this service. This is used to track its cooldown.
+    /// </summary>
+    [DataField("id", required: true)]
+    public string ID = string.Empty;
+
+    /// <summary>
+    /// Prototype of the utility query.
+    /// </summary>
+    [DataField("proto", required: true, customTypeSerializer:typeof(PrototypeIdSerializer<UtilityQueryPrototype>))]
+    public string Prototype = string.Empty;
+
+    [DataField("minCooldown")]
+    public float MinCooldown = 0.25f;
+
+    [DataField("maxCooldown")]
+    public float MaxCooldown = 0.60f;
+
+    /// <summary>
+    /// Key to update with the utility query.
+    /// </summary>
+    [DataField("key", required: true)]
+    public string Key = string.Empty;
+}
index 4b2560f236bcc029655f611b972bec48e07e0f43..27b7156ad258f9586cad0edb150ee5cf709e1a33 100644 (file)
@@ -143,6 +143,9 @@ public sealed partial class NPCCombatSystem
             return;
         }
 
+        // TODO: When I get parallel operators move this as NPC combat shouldn't be handling this.
+        _steering.Register(uid, new EntityCoordinates(component.Target, Vector2.Zero), steering);
+
         if (distance > weapon.Range)
         {
             component.Status = CombatStatus.TargetOutOfRange;
index 0897d8ff1ef606904559df7f56a6f2dddedcbdd3..a99243180bf82008224f8246971de6cd3c61cf27 100644 (file)
@@ -455,7 +455,7 @@ public sealed partial class NPCSteeringSystem
         EntityQuery<PhysicsComponent> bodyQuery,
         EntityQuery<TransformComponent> xformQuery)
     {
-        var detectionRadius = agentRadius + 0.1f;
+        var detectionRadius = MathF.Max(0.35f, agentRadius + 0.1f);
         var ourVelocity = body.LinearVelocity;
         var factionQuery = GetEntityQuery<FactionComponent>();
         factionQuery.TryGetComponent(uid, out var ourFaction);
index 2ccdb7c6f2d3b116bf81b600047039a56cb3c21b..6ea965fed4dfd4c3509cc54e4cba2d1287fa7635 100644 (file)
@@ -168,6 +168,9 @@ public sealed partial class NPCSteeringSystem : SharedNPCSteeringSystem
     {
         if (Resolve(uid, ref component, false))
         {
+            if (component.Coordinates.Equals(coordinates))
+                return component;
+
             component.PathfindToken?.Cancel();
             component.PathfindToken = null;
             component.CurrentPath.Clear();
diff --git a/Content.Server/NPC/Systems/NPCUtilitySystem.cs b/Content.Server/NPC/Systems/NPCUtilitySystem.cs
new file mode 100644 (file)
index 0000000..6547798
--- /dev/null
@@ -0,0 +1,299 @@
+using System.Linq;
+using Content.Server.Examine;
+using Content.Server.NPC.Queries;
+using Content.Server.NPC.Queries.Considerations;
+using Content.Server.NPC.Queries.Curves;
+using Content.Server.NPC.Queries.Queries;
+using Content.Server.Nutrition.Components;
+using Content.Server.Storage.Components;
+using Content.Shared.Examine;
+using Content.Shared.Mobs.Systems;
+using Robust.Server.Containers;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.NPC.Systems;
+
+/// <summary>
+/// Handles utility queries for NPCs.
+/// </summary>
+public sealed class NPCUtilitySystem : EntitySystem
+{
+    [Dependency] private readonly IPrototypeManager _proto = default!;
+    [Dependency] private readonly ContainerSystem _container = default!;
+    [Dependency] private readonly EntityLookupSystem _lookup = default!;
+    [Dependency] private readonly FactionSystem _faction = default!;
+    [Dependency] private readonly MobStateSystem _mobState = default!;
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+    /// <summary>
+    /// Runs the UtilityQueryPrototype and returns the best-matching entities.
+    /// </summary>
+    /// <param name="bestOnly">Should we only return the entity with the best score.</param>
+    public UtilityResult GetEntities(
+        NPCBlackboard blackboard,
+        string proto,
+        bool bestOnly = true)
+    {
+        // TODO: PickHostilesop or whatever needs to juse be UtilityQueryOperator
+
+        var weh = _proto.Index<UtilityQueryPrototype>(proto);
+        var ents = new HashSet<EntityUid>();
+
+        foreach (var query in weh.Query)
+        {
+            switch (query)
+            {
+                case UtilityQueryFilter filter:
+                    Filter(blackboard, ents, filter);
+                    break;
+                default:
+                    Add(blackboard, ents, query);
+                    break;
+            }
+        }
+
+        if (ents.Count == 0)
+            return UtilityResult.Empty;
+
+        var results = new Dictionary<EntityUid, float>();
+        var highestScore = 0f;
+
+        foreach (var ent in ents)
+        {
+            if (results.Count > weh.Limit)
+                break;
+
+            var score = 1f;
+
+            foreach (var con in weh.Considerations)
+            {
+                var conScore = GetScore(blackboard, ent, con);
+                var curve = con.Curve;
+                var curveScore = GetScore(curve, conScore);
+
+                var adjusted = GetAdjustedScore(curveScore, weh.Considerations.Count);
+                score *= adjusted;
+
+                // If the score is too low OR we only care about best entity then early out.
+                // Due to the adjusted score only being able to decrease it can never exceed the highest from here.
+                if (score <= 0f || bestOnly && score <= highestScore)
+                {
+                    break;
+                }
+            }
+
+            if (score <= 0f)
+                continue;
+
+            highestScore = MathF.Max(score, highestScore);
+            results.Add(ent, score);
+        }
+
+        var result = new UtilityResult(results);
+        blackboard.Remove<EntityUid>(NPCBlackboard.UtilityTarget);
+        return result;
+    }
+
+    private float GetScore(IUtilityCurve curve, float conScore)
+    {
+        switch (curve)
+        {
+            case BoolCurve:
+                return conScore > 0f ? 1f : 0f;
+            case InverseBoolCurve:
+                return conScore.Equals(0f) ? 1f : 0f;
+            case PresetCurve presetCurve:
+                return GetScore(_proto.Index<UtilityCurvePresetPrototype>(presetCurve.Preset).Curve, conScore);
+            case QuadraticCurve quadraticCurve:
+                return Math.Clamp(quadraticCurve.Slope * (float) Math.Pow(conScore - quadraticCurve.XOffset, quadraticCurve.Exponent) + quadraticCurve.YOffset, 0f, 1f);
+            default:
+                throw new NotImplementedException();
+        }
+    }
+
+    private float GetScore(NPCBlackboard blackboard, EntityUid targetUid, UtilityConsideration consideration)
+    {
+        switch (consideration)
+        {
+            case FoodValueCon:
+            {
+                if (!TryComp<FoodComponent>(targetUid, out var food))
+                    return 0f;
+
+                return 1f;
+            }
+            case TargetAccessibleCon:
+            {
+                if (_container.TryGetContainingContainer(targetUid, out var container))
+                {
+                    if (TryComp<EntityStorageComponent>(container.Owner, out var storageComponent))
+                    {
+                        if (storageComponent is { IsWeldedShut: true, Open: false })
+                        {
+                            return 0.0f;
+                        }
+                    }
+                    else
+                    {
+                        // If we're in a container (e.g. held or whatever) then we probably can't get it. Only exception
+                        // Is a locker / crate
+                        // TODO: Some mobs can break it so consider that.
+                        return 0.0f;
+                    }
+                }
+
+                // TODO: Pathfind there, though probably do it in a separate con.
+                return 1f;
+            }
+            case TargetDistanceCon:
+            {
+                var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+                var radius = blackboard.GetValueOrDefault<float>(NPCBlackboard.VisionRadius, EntityManager);
+
+                if (!TryComp<TransformComponent>(targetUid, out var targetXform) ||
+                    !TryComp<TransformComponent>(owner, out var xform))
+                {
+                    return 0f;
+                }
+
+                if (!targetXform.Coordinates.TryDistance(EntityManager, _transform, xform.Coordinates,
+                        out var distance))
+                {
+                    return 0f;
+                }
+
+                return Math.Clamp(distance / radius, 0f, 1f);
+            }
+            case TargetHealthCon:
+            {
+                return 0f;
+            }
+            case TargetInLOSCon:
+            {
+                var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+                var radius = blackboard.GetValueOrDefault<float>(NPCBlackboard.VisionRadius, EntityManager);
+
+                return ExamineSystemShared.InRangeUnOccluded(owner, targetUid, radius + 0.5f, null) ? 1f : 0f;
+            }
+            case TargetInLOSOrCurrentCon:
+            {
+                var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+                var radius = blackboard.GetValueOrDefault<float>(NPCBlackboard.VisionRadius, EntityManager);
+                const float bufferRange = 0.5f;
+
+                if (blackboard.TryGetValue<EntityUid>("CombatTarget", out var currentTarget, EntityManager) &&
+                    currentTarget == targetUid &&
+                    TryComp<TransformComponent>(owner, out var xform) &&
+                    TryComp<TransformComponent>(targetUid, out var targetXform) &&
+                    xform.Coordinates.TryDistance(EntityManager, _transform, targetXform.Coordinates, out var distance) &&
+                    distance <= radius + bufferRange)
+                {
+                    return 1f;
+                }
+
+                return ExamineSystemShared.InRangeUnOccluded(owner, targetUid, radius + bufferRange, null) ? 1f : 0f;
+            }
+            case TargetIsAliveCon:
+            {
+                return _mobState.IsAlive(targetUid) ? 1f : 0f;
+            }
+            case TargetIsCritCon:
+            {
+                return _mobState.IsCritical(targetUid) ? 1f : 0f;
+            }
+            case TargetIsDeadCon:
+            {
+                return _mobState.IsDead(targetUid) ? 1f : 0f;
+            }
+            default:
+                throw new NotImplementedException();
+        }
+    }
+
+    private float GetAdjustedScore(float score, int considerations)
+    {
+        /*
+        * Now using the geometric mean
+        * for n scores you take the n-th root of the scores multiplied
+        * e.g. a, b, c scores you take Math.Pow(a * b * c, 1/3)
+        * To get the ACTUAL geometric mean at any one stage you'd need to divide by the running consideration count
+        * however, the downside to this is it will fluctuate up and down over time.
+        * For our purposes if we go below the minimum threshold we want to cut it off, thus we take a
+        * "running geometric mean" which can only ever go down (and by the final value will equal the actual geometric mean).
+        */
+
+        var adjusted = MathF.Pow(score, 1 / (float) considerations);
+        return Math.Clamp(adjusted, 0f, 1f);
+    }
+
+    private void Add(NPCBlackboard blackboard, HashSet<EntityUid> entities, UtilityQuery query)
+    {
+        var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+        var vision = blackboard.GetValueOrDefault<float>(NPCBlackboard.VisionRadius, EntityManager);
+
+        switch (query)
+        {
+            case ComponentQuery compQuery:
+                foreach (var ent in _lookup.GetEntitiesInRange(owner, vision))
+                {
+                    foreach (var comp in compQuery.Components.Values)
+                    {
+                        if (!HasComp(ent, comp.Component.GetType()))
+                            continue;
+
+                        entities.Add(ent);
+                    }
+                }
+
+                break;
+            case NearbyHostilesQuery:
+                foreach (var ent in _faction.GetNearbyHostiles(owner, vision))
+                {
+                    entities.Add(ent);
+                }
+                break;
+            default:
+                throw new NotImplementedException();
+        }
+    }
+
+    private void Filter(NPCBlackboard blackboard, HashSet<EntityUid> entities, UtilityQueryFilter filter)
+    {
+        var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+
+        switch (filter)
+        {
+            default:
+                throw new NotImplementedException();
+        }
+    }
+}
+
+public readonly record struct UtilityResult(Dictionary<EntityUid, float> Entities)
+{
+    public static readonly UtilityResult Empty = new(new Dictionary<EntityUid, float>());
+
+    public readonly Dictionary<EntityUid, float> Entities = Entities;
+
+    /// <summary>
+    /// Returns the entity with the highest score.
+    /// </summary>
+    public EntityUid GetHighest()
+    {
+        if (Entities.Count == 0)
+            return EntityUid.Invalid;
+
+        return Entities.MaxBy(x => x.Value).Key;
+    }
+
+    /// <summary>
+    /// Returns the entity with the lowest score. This does not consider entities with a 0 (invalid) score.
+    /// </summary>
+    public EntityUid GetLowest()
+    {
+        if (Entities.Count == 0)
+            return EntityUid.Invalid;
+
+        return Entities.MinBy(x => x.Value).Key;
+    }
+}
index 5f107180ee46377842b79936915baf56a0f79384..65132cd63320bcbd9534b1c884e234c17d592faa 100644 (file)
@@ -176,7 +176,7 @@ namespace Content.Server.Nutrition.EntitySystems
             // TODO this should really be checked every tick.
             if (!_interactionSystem.InRangeUnobstructed(args.User, args.Target.Value))
                 return;
-            
+
             var forceFeed = args.User != args.Target;
 
             args.Handled = true;
@@ -229,9 +229,6 @@ namespace Content.Server.Nutrition.EntitySystems
 
             if (component.UsesRemaining > 0)
             {
-                if (!forceFeed)
-                    args.Repeat = true;
-
                 return;
             }
 
index 2c4524a288f4066dcaa15c9835e3d934b37ab638..edd531cf159d290a276cdeb94d6d98f3f7c44383 100644 (file)
     equippedPrefix: 0
     slots:
     - HEAD
+  - type: Faction
+    factions:
+      - Mouse
+  - type: HTN
+    rootTask: MouseCompound
   - type: Physics
   - type: Fixtures
     fixtures:
index cc84f9d7bee0153425c568b5b12d73f4df199df5..b37a167abe38c582ac2f196038f8670d1af3cfe0 100644 (file)
   id: MobCatRuntime
   description: Professional mouse hunter. Escape artist.
   components:
+  - type: Faction
+    factions:
+      - PetsNT
+  - type: HTN
+    rootTask: SimpleHostileCompound
   - type: Grammar
     attributes:
       gender: female
index 7e6475178694061d2b0e6167258e3a1b8f6c331d..4bfd50fb632c1890bed528867c0bb899e8e83407 100644 (file)
@@ -36,7 +36,8 @@
 
 - type: htnPrimitive
   id: PickRangedTargetPrimitive
-  operator: !type:PickRangedTargetOperator
+  operator: !type:UtilityOperator
+    proto: NearbyRangedTargets
 
 # Attacks the specified target if they're in LOS.
 - type: htnPrimitive
     - !type:TargetInLOSPrecondition
       targetKey: CombatTarget
       rangeKey: RangedRange
+  services:
+    - !type:UtilityService
+      id: RangedService
+      proto: NearbyRangedTargets
+      key: CombatTarget
 
 
 # -- Melee --
@@ -84,7 +90,8 @@
 
 - type: htnPrimitive
   id: PickMeleeTargetPrimitive
-  operator: !type:PickMeleeTargetOperator
+  operator: !type:UtilityOperator
+    proto: NearbyMeleeTargets
 
 # Attacks the specified target if they're in range.
 - type: htnPrimitive
     - !type:TargetInRangePrecondition
       targetKey: CombatTarget
       rangeKey: MeleeRange
+  services:
+    - !type:UtilityService
+      id: MeleeService
+      proto: NearbyMeleeTargets
+      key: CombatTarget
 
 # Moves the owner into range of the combat target.
 - type: htnPrimitive
index f7410ce4e99b27cceff472bdd82c468bab472b11..e0a8f6a924957429ce86fe0918480673fd9084e2 100644 (file)
@@ -7,6 +7,14 @@
     - tasks:
         - id: IdleCompound
 
+- type: htnCompound
+  id: MouseCompound
+  branches:
+    - tasks:
+        - id: FoodCompound
+    - tasks:
+        - id: IdleCompound
+
 - type: htnCompound
   id: DragonCarpCompound
   branches:
diff --git a/Resources/Prototypes/NPCs/nutrition.yml b/Resources/Prototypes/NPCs/nutrition.yml
new file mode 100644 (file)
index 0000000..9d55b7b
--- /dev/null
@@ -0,0 +1,21 @@
+- type: htnCompound
+  id: FoodCompound
+  branches:
+    - tasks:
+        - id: PickFoodTargetPrimitive
+        - id: MoveToCombatTargetPrimitive
+        - id: EatPrimitive
+        - id: WaitIdleTimePrimitive
+
+
+- type: htnPrimitive
+  id: PickFoodTargetPrimitive
+  operator: !type:UtilityOperator
+    proto: NearbyFood
+
+- type: htnPrimitive
+  id: EatPrimitive
+  preconditions:
+    - !type:KeyExistsPrecondition
+      key: CombatTarget
+  operator: !type:AltInteractOperator
diff --git a/Resources/Prototypes/NPCs/utility_queries.yml b/Resources/Prototypes/NPCs/utility_queries.yml
new file mode 100644 (file)
index 0000000..87eda1f
--- /dev/null
@@ -0,0 +1,71 @@
+- type: utilityQuery
+  id: NearbyFood
+  query:
+    - !type:ComponentQuery
+      components:
+        - type: Food
+  considerations:
+    - !type:TargetIsAliveCon
+      curve: !type:InverseBoolCurve
+    - !type:TargetDistanceCon
+      curve: !type:PresetCurve
+        preset: TargetDistance
+    - !type:FoodValueCon
+      curve: !type:QuadraticCurve
+        slope: 1.0
+        exponent: 0.4
+    - !type:TargetAccessibleCon
+      curve: !type:BoolCurve
+
+- type: utilityQuery
+  id: NearbyMeleeTargets
+  query:
+    - !type:NearbyHostilesQuery
+  considerations:
+    - !type:TargetIsAliveCon
+      curve: !type:BoolCurve
+    - !type:TargetDistanceCon
+      curve: !type:PresetCurve
+        preset: TargetDistance
+    - !type:TargetHealthCon
+      curve: !type:PresetCurve
+        preset: TargetHealth
+    - !type:TargetAccessibleCon
+      curve: !type:BoolCurve
+    - !type:TargetInLOSOrCurrentCon
+      curve: !type:BoolCurve
+
+- type: utilityQuery
+  id: NearbyRangedTargets
+  query:
+    - !type:NearbyHostilesQuery
+  considerations:
+    - !type:TargetIsAliveCon
+      curve: !type:BoolCurve
+    - !type:TargetDistanceCon
+      curve: !type:PresetCurve
+        preset: TargetDistance
+    - !type:TargetHealthCon
+      curve: !type:PresetCurve
+        preset: TargetHealth
+    - !type:TargetAccessibleCon
+      curve: !type:BoolCurve
+    - !type:TargetInLOSOrCurrentCon
+      curve: !type:BoolCurve
+
+
+# Presets
+- type: utilityCurvePreset
+  id: TargetDistance
+  curve: !type:QuadraticCurve
+    slope: -1
+    exponent: 1
+    yOffset: 1
+    xOffset: 0
+
+- type: utilityCurvePreset
+  id: TargetHealth
+  curve: !type:QuadraticCurve
+    slope: 1.0
+    exponent: 0.4
+    xOffset: -0.02
index 40bb8a545536ed533e23cea49fa72166f7cc4fa8..ffa527257dfb1615f3e66cfb0649a666cc67330d 100644 (file)
   - Syndicate
   - Xeno
 
+- type: faction
+  id: Mouse
+  hostile:
+    - PetsNT
+
 - type: faction
   id: PetsNT
   hostile:
+  - Mouse
   - SimpleHostile
   - Xeno