diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index 1d30169019..c6b9b13877 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -88,6 +88,8 @@ namespace Content.Client.Entry _componentFactory.RegisterClass(); // 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. diff --git a/Content.Client/NPC/HTN/HTNOverlay.cs b/Content.Client/NPC/HTN/HTNOverlay.cs index 30234cb31a..0d1a4f8c8a 100644 --- a/Content.Client/NPC/HTN/HTNOverlay.cs +++ b/Content.Client/NPC/HTN/HTNOverlay.cs @@ -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); } } } diff --git a/Content.Server/NPC/HTN/HTNComponent.cs b/Content.Server/NPC/HTN/HTNComponent.cs index 4fa5f216be..aabfe99ad2 100644 --- a/Content.Server/NPC/HTN/HTNComponent.cs +++ b/Content.Server/NPC/HTN/HTNComponent.cs @@ -20,6 +20,13 @@ public sealed class HTNComponent : NPCComponent [ViewVariables] public HTNPlan? Plan; + // TODO: Need dictionary timeoffsetserializer. + /// + /// Last time we tried a particular . + /// + [DataField("serviceCooldowns")] + public Dictionary ServiceCooldowns = new(); + /// /// How long to wait after having planned to try planning again. /// @@ -42,6 +49,4 @@ public sealed class HTNComponent : NPCComponent /// Is this NPC currently planning? /// [ViewVariables] public bool Planning => PlanningJob != null; - - } diff --git a/Content.Server/NPC/HTN/HTNSystem.cs b/Content.Server/NPC/HTN/HTNSystem.cs index 8ed8708c1f..9beabeef7a 100644 --- a/Content.Server/NPC/HTN/HTNSystem.cs +++ b/Content.Server/NPC/HTN/HTNSystem.cs @@ -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 _subscribers = new(); @@ -37,12 +42,22 @@ public sealed class HTNSystem : EntitySystem base.Initialize(); _sawmill = Logger.GetSawmill("npc.htn"); SubscribeLocalEvent(OnHTNShutdown); + SubscribeLocalEvent(OnHTNUnpaused); SubscribeNetworkEvent(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; } diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/HTNPrimitiveTask.cs b/Content.Server/NPC/HTN/PrimitiveTasks/HTNPrimitiveTask.cs index f9d671041b..2c574ba298 100644 --- a/Content.Server/NPC/HTN/PrimitiveTasks/HTNPrimitiveTask.cs +++ b/Content.Server/NPC/HTN/PrimitiveTasks/HTNPrimitiveTask.cs @@ -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 Preconditions = new(); [DataField("operator", required:true)] public HTNOperator Operator = default!; + + /// + /// Services actively tick and can potentially update keys, such as combat target. + /// + [DataField("services")] public List 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 index 0000000000..4229886059 --- /dev/null +++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/AltInteractOperator.cs @@ -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"; + + /// + /// If this alt-interaction started a do_after where does the key get stored. + /// + [DataField("idleKey")] + public string IdleKey = "IdleTime"; + + public override async Task<(bool Valid, Dictionary? Effects)> Plan(NPCBlackboard blackboard, CancellationToken cancelToken) + { + return new(true, new Dictionary() + { + { IdleKey, 1f } + }); + } + + public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime) + { + var owner = blackboard.GetValue(NPCBlackboard.Owner); + var target = blackboard.GetValue(Key); + var intSystem = _entManager.System(); + var count = 0; + + if (_entManager.TryGetComponent(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; + } +} diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/MeleeOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/MeleeOperator.cs index 80516d944d..8831b87850 100644 --- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/MeleeOperator.cs +++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/MeleeOperator.cs @@ -45,7 +45,6 @@ public sealed class MeleeOperator : HTNOperator } if (_entManager.TryGetComponent(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(NPCBlackboard.Owner); - var status = HTNOperatorStatus.Continuing; + HTNOperatorStatus status; - if (_entManager.TryGetComponent(owner, out var combat)) + if (_entManager.TryGetComponent(owner, out var combat) && + blackboard.TryGetValue(TargetKey, out var target, _entManager)) { + combat.Target = target; + // Success - if (_entManager.TryGetComponent(combat.Target, out var mobState) && - mobState.CurrentState != null && + if (_entManager.TryGetComponent(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 index 5bbfc98dbc..0000000000 --- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Melee/PickMeleeTargetOperator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using JetBrains.Annotations; - -namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Melee; - -/// -/// Selects a target for melee. -/// -[MeansImplicitUse] -public sealed class PickMeleeTargetOperator : NPCCombatOperator -{ - protected override float GetRating(NPCBlackboard blackboard, EntityUid uid, EntityUid existingTarget, float distance, bool canMove, EntityQuery 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 index 9b22f70da9..0000000000 --- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/NPCCombatOperator.cs +++ /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"; - - /// - /// The EntityCoordinates of the specified target. - /// - [DataField("keyCoordinates")] - public string KeyCoordinates = "CombatTargetCoordinates"; - - /// - /// Regardless of pathfinding or LOS these are the max we'll check - /// - private const int MaxConsideredTargets = 10; - - protected virtual bool IsRanged => false; - - public override void Initialize(IEntitySystemManager sysManager) - { - base.Initialize(sysManager); - sysManager.GetEntitySystem(); - _factions = sysManager.GetEntitySystem(); - _factionException = sysManager.GetEntitySystem(); - Interaction = sysManager.GetEntitySystem(); - _pathfinding = sysManager.GetEntitySystem(); - } - - public override async Task<(bool Valid, Dictionary? 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() - { - {Key, selectedTarget}, - {KeyCoordinates, new EntityCoordinates(selectedTarget, Vector2.Zero)} - }; - - return (true, effects); - } - - private async Task> GetTargets(NPCBlackboard blackboard) - { - var owner = blackboard.GetValue(NPCBlackboard.Owner); - var ownerCoordinates = blackboard.GetValueOrDefault(NPCBlackboard.OwnerCoordinates, EntManager); - var radius = blackboard.GetValueOrDefault(NPCBlackboard.VisionRadius, EntManager); - var targets = new List<(EntityUid Entity, float Rating, float Distance)>(); - - blackboard.TryGetValue(Key, out var existingTarget, EntManager); - var xformQuery = EntManager.GetEntityQuery(); - var mobQuery = EntManager.GetEntityQuery(); - var canMove = blackboard.GetValueOrDefault(NPCBlackboard.CanMove, EntManager); - var count = 0; - var paths = new List(); - // 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(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 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 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 index f1f1244ff2..0000000000 --- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/PickRangedTargetOperator.cs +++ /dev/null @@ -1,27 +0,0 @@ -using JetBrains.Annotations; - -namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Ranged; - -/// -/// Selects a target for ranged combat. -/// -[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 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; - } -} diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/RangedOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/RangedOperator.cs index c4e2fb3afd..3446cd964a 100644 --- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/RangedOperator.cs +++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Ranged/RangedOperator.cs @@ -35,7 +35,6 @@ public sealed class RangedOperator : HTNOperator } if (_entManager.TryGetComponent(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(NPCBlackboard.Owner); - var status = HTNOperatorStatus.Continuing; + HTNOperatorStatus status; - if (_entManager.TryGetComponent(owner, out var combat)) + if (_entManager.TryGetComponent(owner, out var combat) && + blackboard.TryGetValue(TargetKey, out var target, _entManager)) { + combat.Target = target; + // Success if (_entManager.TryGetComponent(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 index 0000000000..523b24ddf2 --- /dev/null +++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/UtilityOperator.cs @@ -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; + +/// +/// Utilises a to determine the best target and sets it to the Key. +/// +public sealed class UtilityOperator : HTNOperator +{ + [Dependency] private readonly IEntityManager _entManager = default!; + + [DataField("key")] public string Key = "CombatTarget"; + + /// + /// The EntityCoordinates of the specified target. + /// + [DataField("keyCoordinates")] + public string KeyCoordinates = "CombatTargetCoordinates"; + + [DataField("proto", required: true, customTypeSerializer:typeof(PrototypeIdSerializer))] + public string Prototype = string.Empty; + + public override async Task<(bool Valid, Dictionary? Effects)> Plan(NPCBlackboard blackboard, + CancellationToken cancelToken) + { + var result = _entManager.System().GetEntities(blackboard, Prototype); + var target = result.GetHighest(); + + if (!target.IsValid()) + { + return (false, new Dictionary()); + } + + var effects = new Dictionary() + { + {Key, target}, + {KeyCoordinates, new EntityCoordinates(target, Vector2.Zero)} + }; + + return (true, effects); + } +} diff --git a/Content.Server/NPC/NPCBlackboard.cs b/Content.Server/NPC/NPCBlackboard.cs index 0e12827cd7..4e8581efb2 100644 --- a/Content.Server/NPC/NPCBlackboard.cs +++ b/Content.Server/NPC/NPCBlackboard.cs @@ -27,9 +27,9 @@ public sealed class NPCBlackboard : IEnumerable> {"MeleeRange", 1f}, {"MinimumIdleTime", 2f}, {"MovementRange", 1.5f}, - {"RangedRange", 7f}, + {"RangedRange", 10f}, {"RotateSpeed", MathF.PI}, - {"VisionRadius", 7f}, + {"VisionRadius", 10f}, }; /// @@ -228,6 +228,7 @@ public sealed class NPCBlackboard : IEnumerable> public const string RotateSpeed = "RotateSpeed"; public const string VisionRadius = "VisionRadius"; + public const string UtilityTarget = "Target"; public IEnumerator> GetEnumerator() { diff --git a/Content.Server/NPC/Queries/Considerations/FoodValueCon.cs b/Content.Server/NPC/Queries/Considerations/FoodValueCon.cs new file mode 100644 index 0000000000..c10213f3f1 --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/FoodValueCon.cs @@ -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 index 0000000000..d47f7417eb --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetAccessibleCon.cs @@ -0,0 +1,9 @@ +namespace Content.Server.NPC.Queries.Considerations; + +/// +/// Returns 1f if the target is freely accessible (e.g. not in locked storage). +/// +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 index 0000000000..a2cb99baf1 --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetDistanceCon.cs @@ -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 index 0000000000..a6496670af --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetHealthCon.cs @@ -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 index 0000000000..219a301f0b --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetInLOSCon.cs @@ -0,0 +1,9 @@ +namespace Content.Server.NPC.Queries.Considerations; + +/// +/// Returns whether the target is in line-of-sight. +/// +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 index 0000000000..2919a91c11 --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetInLOSOrCurrentCon.cs @@ -0,0 +1,9 @@ +namespace Content.Server.NPC.Queries.Considerations; + +/// +/// Placeholder considerations -> returns 1f if they're in LOS or the current target. +/// +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 index 0000000000..3c74811ca6 --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetIsAliveCon.cs @@ -0,0 +1,9 @@ +namespace Content.Server.NPC.Queries.Considerations; + +/// +/// Returns 1f if the target is alive or 0f if not. +/// +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 index 0000000000..03c73a14df --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetIsCritCon.cs @@ -0,0 +1,9 @@ +namespace Content.Server.NPC.Queries.Considerations; + +/// +/// Returns 1f if the target is crit or 0f if not. +/// +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 index 0000000000..53e0a9b595 --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/TargetIsDeadCon.cs @@ -0,0 +1,9 @@ +namespace Content.Server.NPC.Queries.Considerations; + +/// +/// Returns 1f if the target is dead or 0f if not. +/// +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 index 0000000000..e74b7aa959 --- /dev/null +++ b/Content.Server/NPC/Queries/Considerations/UtilityConsideration.cs @@ -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 index 0000000000..70651167a9 --- /dev/null +++ b/Content.Server/NPC/Queries/Curves/BoolCurve.cs @@ -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 index 0000000000..db48f32d2e --- /dev/null +++ b/Content.Server/NPC/Queries/Curves/IUtilityCurve.cs @@ -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 index 0000000000..13b31771c7 --- /dev/null +++ b/Content.Server/NPC/Queries/Curves/InverseBoolCurve.cs @@ -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 index 0000000000..689be8e2ec --- /dev/null +++ b/Content.Server/NPC/Queries/Curves/PresetCurve.cs @@ -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))] 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 index 0000000000..3dead779af --- /dev/null +++ b/Content.Server/NPC/Queries/Curves/QuadraticCurve.cs @@ -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 index 0000000000..5ac127c053 --- /dev/null +++ b/Content.Server/NPC/Queries/Curves/UtilityCurvePresetPrototype.cs @@ -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 index 0000000000..2ca04e1369 --- /dev/null +++ b/Content.Server/NPC/Queries/Queries/ClothingSlotFilter.cs @@ -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 index 0000000000..9e101ec27a --- /dev/null +++ b/Content.Server/NPC/Queries/Queries/ComponentQuery.cs @@ -0,0 +1,12 @@ +using Robust.Shared.Prototypes; + +namespace Content.Server.NPC.Queries.Queries; + +/// +/// Returns nearby components that match the specified components. +/// +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 index 0000000000..7741c6beb5 --- /dev/null +++ b/Content.Server/NPC/Queries/Queries/NearbyComponentsQuery.cs @@ -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 index 0000000000..f3b151832b --- /dev/null +++ b/Content.Server/NPC/Queries/Queries/NearbyHostilesQuery.cs @@ -0,0 +1,9 @@ +namespace Content.Server.NPC.Queries.Queries; + +/// +/// Returns nearby entities considered hostile from +/// +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 index 0000000000..791e5bee82 --- /dev/null +++ b/Content.Server/NPC/Queries/Queries/PuddlesQuery.cs @@ -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 index 0000000000..401d41c2a1 --- /dev/null +++ b/Content.Server/NPC/Queries/Queries/UtilityQuery.cs @@ -0,0 +1,10 @@ +namespace Content.Server.NPC.Queries.Queries; + +/// +/// Adds entities to a query. +/// +[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 index 0000000000..906cf23be3 --- /dev/null +++ b/Content.Server/NPC/Queries/Queries/UtilityQueryFilter.cs @@ -0,0 +1,9 @@ +namespace Content.Server.NPC.Queries.Queries; + +/// +/// Removes entities from a query. +/// +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 index 0000000000..934ac89926 --- /dev/null +++ b/Content.Server/NPC/Queries/UtilityQueryPrototype.cs @@ -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; + +/// +/// 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. +/// +[Prototype("utilityQuery")] +public sealed class UtilityQueryPrototype : IPrototype +{ + [IdDataField] + public string ID { get; } = default!; + + [ViewVariables(VVAccess.ReadWrite), DataField("query")] + public List Query = new(); + + [ViewVariables(VVAccess.ReadWrite), DataField("considerations")] + public List Considerations = new(); + + /// + /// How many entities we are allowed to consider. This is applied after all queries have run. + /// + [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 index 0000000000..d8b0a3457d --- /dev/null +++ b/Content.Server/NPC/Queries/UtilityService.cs @@ -0,0 +1,34 @@ +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Server.NPC.Queries; + +/// +/// Utility queries that run regularly to update an NPC without re-doing their thinking logic. +/// +[DataDefinition] +public sealed class UtilityService +{ + /// + /// Identifier to use for this service. This is used to track its cooldown. + /// + [DataField("id", required: true)] + public string ID = string.Empty; + + /// + /// Prototype of the utility query. + /// + [DataField("proto", required: true, customTypeSerializer:typeof(PrototypeIdSerializer))] + public string Prototype = string.Empty; + + [DataField("minCooldown")] + public float MinCooldown = 0.25f; + + [DataField("maxCooldown")] + public float MaxCooldown = 0.60f; + + /// + /// Key to update with the utility query. + /// + [DataField("key", required: true)] + public string Key = string.Empty; +} diff --git a/Content.Server/NPC/Systems/NPCCombatSystem.Melee.cs b/Content.Server/NPC/Systems/NPCCombatSystem.Melee.cs index 4b2560f236..27b7156ad2 100644 --- a/Content.Server/NPC/Systems/NPCCombatSystem.Melee.cs +++ b/Content.Server/NPC/Systems/NPCCombatSystem.Melee.cs @@ -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; diff --git a/Content.Server/NPC/Systems/NPCSteeringSystem.Context.cs b/Content.Server/NPC/Systems/NPCSteeringSystem.Context.cs index 0897d8ff1e..a99243180b 100644 --- a/Content.Server/NPC/Systems/NPCSteeringSystem.Context.cs +++ b/Content.Server/NPC/Systems/NPCSteeringSystem.Context.cs @@ -455,7 +455,7 @@ public sealed partial class NPCSteeringSystem EntityQuery bodyQuery, EntityQuery xformQuery) { - var detectionRadius = agentRadius + 0.1f; + var detectionRadius = MathF.Max(0.35f, agentRadius + 0.1f); var ourVelocity = body.LinearVelocity; var factionQuery = GetEntityQuery(); factionQuery.TryGetComponent(uid, out var ourFaction); diff --git a/Content.Server/NPC/Systems/NPCSteeringSystem.cs b/Content.Server/NPC/Systems/NPCSteeringSystem.cs index 2ccdb7c6f2..6ea965fed4 100644 --- a/Content.Server/NPC/Systems/NPCSteeringSystem.cs +++ b/Content.Server/NPC/Systems/NPCSteeringSystem.cs @@ -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 index 0000000000..65477982de --- /dev/null +++ b/Content.Server/NPC/Systems/NPCUtilitySystem.cs @@ -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; + +/// +/// Handles utility queries for NPCs. +/// +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!; + + /// + /// Runs the UtilityQueryPrototype and returns the best-matching entities. + /// + /// Should we only return the entity with the best score. + public UtilityResult GetEntities( + NPCBlackboard blackboard, + string proto, + bool bestOnly = true) + { + // TODO: PickHostilesop or whatever needs to juse be UtilityQueryOperator + + var weh = _proto.Index(proto); + var ents = new HashSet(); + + 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(); + 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(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(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(targetUid, out var food)) + return 0f; + + return 1f; + } + case TargetAccessibleCon: + { + if (_container.TryGetContainingContainer(targetUid, out var container)) + { + if (TryComp(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(NPCBlackboard.Owner); + var radius = blackboard.GetValueOrDefault(NPCBlackboard.VisionRadius, EntityManager); + + if (!TryComp(targetUid, out var targetXform) || + !TryComp(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(NPCBlackboard.Owner); + var radius = blackboard.GetValueOrDefault(NPCBlackboard.VisionRadius, EntityManager); + + return ExamineSystemShared.InRangeUnOccluded(owner, targetUid, radius + 0.5f, null) ? 1f : 0f; + } + case TargetInLOSOrCurrentCon: + { + var owner = blackboard.GetValue(NPCBlackboard.Owner); + var radius = blackboard.GetValueOrDefault(NPCBlackboard.VisionRadius, EntityManager); + const float bufferRange = 0.5f; + + if (blackboard.TryGetValue("CombatTarget", out var currentTarget, EntityManager) && + currentTarget == targetUid && + TryComp(owner, out var xform) && + TryComp(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 entities, UtilityQuery query) + { + var owner = blackboard.GetValue(NPCBlackboard.Owner); + var vision = blackboard.GetValueOrDefault(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 entities, UtilityQueryFilter filter) + { + var owner = blackboard.GetValue(NPCBlackboard.Owner); + + switch (filter) + { + default: + throw new NotImplementedException(); + } + } +} + +public readonly record struct UtilityResult(Dictionary Entities) +{ + public static readonly UtilityResult Empty = new(new Dictionary()); + + public readonly Dictionary Entities = Entities; + + /// + /// Returns the entity with the highest score. + /// + public EntityUid GetHighest() + { + if (Entities.Count == 0) + return EntityUid.Invalid; + + return Entities.MaxBy(x => x.Value).Key; + } + + /// + /// Returns the entity with the lowest score. This does not consider entities with a 0 (invalid) score. + /// + public EntityUid GetLowest() + { + if (Entities.Count == 0) + return EntityUid.Invalid; + + return Entities.MinBy(x => x.Value).Key; + } +} diff --git a/Content.Server/Nutrition/EntitySystems/FoodSystem.cs b/Content.Server/Nutrition/EntitySystems/FoodSystem.cs index 5f107180ee..65132cd633 100644 --- a/Content.Server/Nutrition/EntitySystems/FoodSystem.cs +++ b/Content.Server/Nutrition/EntitySystems/FoodSystem.cs @@ -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; } diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml index 2c4524a288..edd531cf15 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml @@ -786,6 +786,11 @@ equippedPrefix: 0 slots: - HEAD + - type: Faction + factions: + - Mouse + - type: HTN + rootTask: MouseCompound - type: Physics - type: Fixtures fixtures: diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml index cc84f9d7be..b37a167abe 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml @@ -107,6 +107,11 @@ id: MobCatRuntime description: Professional mouse hunter. Escape artist. components: + - type: Faction + factions: + - PetsNT + - type: HTN + rootTask: SimpleHostileCompound - type: Grammar attributes: gender: female diff --git a/Resources/Prototypes/NPCs/attack.yml b/Resources/Prototypes/NPCs/attack.yml index 7e64751786..4bfd50fb63 100644 --- a/Resources/Prototypes/NPCs/attack.yml +++ b/Resources/Prototypes/NPCs/attack.yml @@ -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 @@ -53,6 +54,11 @@ - !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 @@ -97,6 +104,11 @@ - !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 diff --git a/Resources/Prototypes/NPCs/mob.yml b/Resources/Prototypes/NPCs/mob.yml index f7410ce4e9..e0a8f6a924 100644 --- a/Resources/Prototypes/NPCs/mob.yml +++ b/Resources/Prototypes/NPCs/mob.yml @@ -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 index 0000000000..9d55b7be21 --- /dev/null +++ b/Resources/Prototypes/NPCs/nutrition.yml @@ -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 index 0000000000..87eda1f9eb --- /dev/null +++ b/Resources/Prototypes/NPCs/utility_queries.yml @@ -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 diff --git a/Resources/Prototypes/ai_factions.yml b/Resources/Prototypes/ai_factions.yml index 40bb8a5455..ffa527257d 100644 --- a/Resources/Prototypes/ai_factions.yml +++ b/Resources/Prototypes/ai_factions.yml @@ -12,9 +12,15 @@ - Syndicate - Xeno +- type: faction + id: Mouse + hostile: + - PetsNT + - type: faction id: PetsNT hostile: + - Mouse - SimpleHostile - Xeno