_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.
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);
}
}
}
[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>
/// Is this NPC currently planning?
/// </summary>
[ViewVariables] public bool Planning => PlanningJob != null;
-
-
}
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();
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))
// If it's the selected BTR then highlight.
for (var i = 0; i < btr.Count; i++)
{
- text.Append('-');
+ text.Append("--");
}
text.Append(' ');
{
var branch = branches[i];
btr.Add(i);
- text.AppendLine($" branch {string.Join(" ", btr)}:");
+ text.AppendLine($" branch {string.Join(", ", btr)}:");
foreach (var sub in branch)
{
{
// 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)
break;
case HTNOperatorStatus.Failed:
currentOperator.Shutdown(blackboard, status);
+ component.ServiceCooldowns.Clear();
component.Plan = null;
break;
// Operator completed so go to the next one.
// Plan finished!
if (component.Plan.Tasks.Count <= component.Plan.Index)
{
+ component.ServiceCooldowns.Clear();
component.Plan = null;
break;
}
using Content.Server.NPC.HTN.Preconditions;
+using Content.Server.NPC.Queries;
using Robust.Shared.Prototypes;
namespace Content.Server.NPC.HTN.PrimitiveTasks;
[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();
}
--- /dev/null
+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;
+ }
+}
}
if (_entManager.TryGetComponent<MobStateComponent>(target, out var mobState) &&
- mobState.CurrentState != null &&
mobState.CurrentState > TargetState)
{
return (false, null);
{
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;
}
}
}
+ else
+ {
+ status = HTNOperatorStatus.Failed;
+ }
if (status != HTNOperatorStatus.Continuing)
{
+++ /dev/null
-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;
- }
-}
+++ /dev/null
-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);
-}
+++ /dev/null
-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;
- }
-}
}
if (_entManager.TryGetComponent<MobStateComponent>(target, out var mobState) &&
- mobState.CurrentState != null &&
mobState.CurrentState > TargetState)
{
return (false, null);
{
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;
}
}
}
+ else
+ {
+ status = HTNOperatorStatus.Failed;
+ }
if (status != HTNOperatorStatus.Continuing)
{
--- /dev/null
+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);
+ }
+}
{"MeleeRange", 1f},
{"MinimumIdleTime", 2f},
{"MovementRange", 1.5f},
- {"RangedRange", 7f},
+ {"RangedRange", 10f},
{"RotateSpeed", MathF.PI},
- {"VisionRadius", 7f},
+ {"VisionRadius", 10f},
};
/// <summary>
public const string RotateSpeed = "RotateSpeed";
public const string VisionRadius = "VisionRadius";
+ public const string UtilityTarget = "Target";
public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
{
--- /dev/null
+namespace Content.Server.NPC.Queries.Considerations;
+
+public sealed class FoodValueCon : UtilityConsideration
+{
+
+}
--- /dev/null
+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
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Considerations;
+
+public sealed class TargetDistanceCon : UtilityConsideration
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Considerations;
+
+public sealed class TargetHealthCon : UtilityConsideration
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns whether the target is in line-of-sight.
+/// </summary>
+public sealed class TargetInLOSCon : UtilityConsideration
+{
+
+}
--- /dev/null
+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
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns 1f if the target is alive or 0f if not.
+/// </summary>
+public sealed class TargetIsAliveCon : UtilityConsideration
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns 1f if the target is crit or 0f if not.
+/// </summary>
+public sealed class TargetIsCritCon : UtilityConsideration
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Considerations;
+
+/// <summary>
+/// Returns 1f if the target is dead or 0f if not.
+/// </summary>
+public sealed class TargetIsDeadCon : UtilityConsideration
+{
+
+}
--- /dev/null
+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!;
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Curves;
+
+public sealed class BoolCurve : IUtilityCurve
+{
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Curves;
+
+[ImplicitDataDefinitionForInheritors]
+public interface IUtilityCurve
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Curves;
+
+public sealed class InverseBoolCurve : IUtilityCurve
+{
+
+}
--- /dev/null
+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!;
+}
--- /dev/null
+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;
+}
--- /dev/null
+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!;
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Queries;
+
+public sealed class ClothingSlotFilter : UtilityQueryFilter
+{
+
+}
--- /dev/null
+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!;
+}
--- /dev/null
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.NPC.Queries.Queries;
+
+public sealed class NearbyComponentsQuery : UtilityQuery
+{
+ [DataField("components")]
+ public EntityPrototype.ComponentRegistry Component = default!;
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Queries;
+
+/// <summary>
+/// Returns nearby entities considered hostile from <see cref="FactionSystem"/>
+/// </summary>
+public sealed class NearbyHostilesQuery : UtilityQuery
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Queries;
+
+public sealed class PuddlesQuery : UtilityQuery
+{
+
+}
--- /dev/null
+namespace Content.Server.NPC.Queries.Queries;
+
+/// <summary>
+/// Adds entities to a query.
+/// </summary>
+[ImplicitDataDefinitionForInheritors]
+public abstract class UtilityQuery
+{
+
+}
\ No newline at end of file
--- /dev/null
+namespace Content.Server.NPC.Queries.Queries;
+
+/// <summary>
+/// Removes entities from a query.
+/// </summary>
+public abstract class UtilityQueryFilter : UtilityQuery
+{
+
+}
\ No newline at end of file
--- /dev/null
+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;
+}
--- /dev/null
+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;
+}
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;
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);
{
if (Resolve(uid, ref component, false))
{
+ if (component.Coordinates.Equals(coordinates))
+ return component;
+
component.PathfindToken?.Cancel();
component.PathfindToken = null;
component.CurrentPath.Clear();
--- /dev/null
+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;
+ }
+}
// 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;
if (component.UsesRemaining > 0)
{
- if (!forceFeed)
- args.Repeat = true;
-
return;
}
equippedPrefix: 0
slots:
- HEAD
+ - type: Faction
+ factions:
+ - Mouse
+ - type: HTN
+ rootTask: MouseCompound
- type: Physics
- type: Fixtures
fixtures:
id: MobCatRuntime
description: Professional mouse hunter. Escape artist.
components:
+ - type: Faction
+ factions:
+ - PetsNT
+ - type: HTN
+ rootTask: SimpleHostileCompound
- type: Grammar
attributes:
gender: female
- 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 --
- 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
- tasks:
- id: IdleCompound
+- type: htnCompound
+ id: MouseCompound
+ branches:
+ - tasks:
+ - id: FoodCompound
+ - tasks:
+ - id: IdleCompound
+
- type: htnCompound
id: DragonCarpCompound
branches:
--- /dev/null
+- 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
--- /dev/null
+- 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
- Syndicate
- Xeno
+- type: faction
+ id: Mouse
+ hostile:
+ - PetsNT
+
- type: faction
id: PetsNT
hostile:
+ - Mouse
- SimpleHostile
- Xeno