using System.Linq; using System.Text; using System.Threading; using Content.Server.Administration.Managers; using Robust.Shared.CPUJob.JobQueues; using Robust.Shared.CPUJob.JobQueues.Queues; using Content.Server.NPC.HTN.PrimitiveTasks; using Content.Server.NPC.Systems; using Content.Shared.Administration; using Content.Shared.Mobs; using Content.Shared.NPC; using JetBrains.Annotations; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Utility; namespace Content.Server.NPC.HTN; public sealed class HTNSystem : EntitySystem { [Dependency] private readonly IAdminManager _admin = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly NPCSystem _npc = default!; [Dependency] private readonly NPCUtilitySystem _utility = default!; private readonly JobQueue _planQueue = new(0.004); private readonly HashSet _subscribers = new(); // Hierarchical Task Network public override void Initialize() { base.Initialize(); SubscribeLocalEvent(_npc.OnMobStateChange); SubscribeLocalEvent(_npc.OnNPCMapInit); SubscribeLocalEvent(_npc.OnPlayerNPCAttach); SubscribeLocalEvent(_npc.OnPlayerNPCDetach); SubscribeLocalEvent(OnHTNShutdown); SubscribeNetworkEvent(OnHTNMessage); SubscribeLocalEvent(OnPrototypeLoad); OnLoad(); } private void OnHTNMessage(RequestHTNMessage msg, EntitySessionEventArgs args) { if (!_admin.HasAdminFlag(args.SenderSession, AdminFlags.Debug)) { _subscribers.Remove(args.SenderSession); return; } if (_subscribers.Add(args.SenderSession)) return; _subscribers.Remove(args.SenderSession); } private void OnLoad() { // Clear all NPCs in case they're hanging onto stale tasks var query = AllEntityQuery(); while (query.MoveNext(out var comp)) { comp.PlanningToken?.Cancel(); comp.PlanningToken = null; if (comp.Plan != null) { var currentOperator = comp.Plan.CurrentOperator; ShutdownTask(currentOperator, comp.Blackboard, HTNOperatorStatus.Failed); ShutdownPlan(comp); comp.Plan = null; RequestPlan(comp); } } // Add dependencies for all operators. // We put code on operators as I couldn't think of a clean way to put it on systems. foreach (var compound in _prototypeManager.EnumeratePrototypes()) { UpdateCompound(compound); } } private void OnPrototypeLoad(PrototypesReloadedEventArgs obj) { OnLoad(); } private void UpdateCompound(HTNCompoundPrototype compound) { for (var i = 0; i < compound.Branches.Count; i++) { var branch = compound.Branches[i]; foreach (var precon in branch.Preconditions) { precon.Initialize(EntityManager.EntitySysManager); } foreach (var task in branch.Tasks) { UpdateTask(task); } } } private void UpdateTask(HTNTask task) { switch (task) { case HTNCompoundTask: // NOOP, handled elsewhere break; case HTNPrimitiveTask primitive: foreach (var precon in primitive.Preconditions) { precon.Initialize(EntityManager.EntitySysManager); } primitive.Operator.Initialize(EntityManager.EntitySysManager); break; default: throw new NotImplementedException(); } } private void OnHTNShutdown(EntityUid uid, HTNComponent component, ComponentShutdown args) { _npc.OnNPCShutdown(uid, component, args); component.PlanningToken?.Cancel(); component.PlanningJob = null; } /// /// Enable / disable the hierarchical task network of an entity /// /// The entity and its /// Set 'true' to enable, or 'false' to disable, the HTN /// Specifies a time in seconds before the entity can start planning a new action (only takes effect when the HTN is enabled) // ReSharper disable once InconsistentNaming [PublicAPI] public void SetHTNEnabled(Entity ent, bool state, float planCooldown = 0f) { if (ent.Comp.Enabled == state) return; ent.Comp.Enabled = state; ent.Comp.PlanAccumulator = planCooldown; ent.Comp.PlanningToken?.Cancel(); ent.Comp.PlanningToken = null; if (ent.Comp.Plan != null) { var currentOperator = ent.Comp.Plan.CurrentOperator; ShutdownTask(currentOperator, ent.Comp.Blackboard, HTNOperatorStatus.Failed); ShutdownPlan(ent.Comp); ent.Comp.Plan = null; } if (ent.Comp.Enabled && ent.Comp.PlanAccumulator <= 0) RequestPlan(ent.Comp); } /// /// Forces the NPC to replan. /// [PublicAPI] public void Replan(HTNComponent component) { component.PlanAccumulator = 0f; } public void UpdateNPC(ref int count, int maxUpdates, float frameTime) { _planQueue.Process(); var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out _, out var comp)) { // If we're over our max count or it's not MapInit then ignore the NPC. if (count >= maxUpdates) break; if (!comp.Enabled) continue; if (comp.PlanningJob != null) { if (comp.PlanningJob.Exception != null) { Log.Fatal($"Received exception on planning job for {uid}!"); _npc.SleepNPC(uid); var exc = comp.PlanningJob.Exception; RemComp(uid); throw exc; } // If a new planning job has finished then handle it. if (comp.PlanningJob.Status != JobStatus.Finished) continue; var newPlanBetter = false; // If old traversal is better than new traversal then ignore the new plan if (comp.Plan != null && comp.PlanningJob.Result != null) { var oldMtr = comp.Plan.BranchTraversalRecord; var mtr = comp.PlanningJob.Result.BranchTraversalRecord; for (var i = 0; i < oldMtr.Count; i++) { if (i < mtr.Count && oldMtr[i] > mtr[i]) { newPlanBetter = true; break; } } } if (comp.Plan == null || newPlanBetter) { comp.CheckServices = false; if (comp.Plan != null) { ShutdownTask(comp.Plan.CurrentOperator, comp.Blackboard, HTNOperatorStatus.BetterPlan); ShutdownPlan(comp); } comp.Plan = comp.PlanningJob.Result; // Startup the first task and anything else we need to do. if (comp.Plan != null) { StartupTask(comp.Plan.Tasks[comp.Plan.Index], comp.Blackboard, comp.Plan.Effects[comp.Plan.Index]); } // Send debug info foreach (var session in _subscribers) { var text = new StringBuilder(); if (comp.Plan != null) { text.AppendLine($"BTR: {string.Join(", ", comp.Plan.BranchTraversalRecord)}"); text.AppendLine($"tasks:"); var root = comp.RootTask; var btr = new List(); var level = -1; AppendDebugText(root, text, comp.Plan.BranchTraversalRecord, btr, ref level); } RaiseNetworkEvent(new HTNMessage() { Uid = GetNetEntity(uid), Text = text.ToString(), }, session.Channel); } } // Keeping old plan else { comp.CheckServices = true; } comp.PlanningJob = null; comp.PlanningToken = null; } Update(comp, frameTime); count++; } } private void AppendDebugText(HTNTask task, StringBuilder text, List planBtr, List btr, ref int level) { // If it's the selected BTR then highlight. for (var i = 0; i < btr.Count; i++) { text.Append("--"); } text.Append(' '); if (task is HTNPrimitiveTask primitive) { text.AppendLine(primitive.ToString()); return; } if (task is HTNCompoundTask compTask) { var compound = _prototypeManager.Index(compTask.Task); level++; text.AppendLine(compound.ID); var branches = compound.Branches; for (var i = 0; i < branches.Count; i++) { var branch = branches[i]; btr.Add(i); text.AppendLine($" branch {string.Join(", ", btr)}:"); foreach (var sub in branch.Tasks) { AppendDebugText(sub, text, planBtr, btr, ref level); } btr.RemoveAt(btr.Count - 1); } level--; return; } throw new NotImplementedException(); } private void Update(HTNComponent component, float frameTime) { // If we're not planning then countdown to next one. if (component.PlanningJob == null) component.PlanAccumulator -= frameTime; // We'll still try re-planning occasionally even when we're updating in case new data comes in. if (component.PlanAccumulator <= 0f) { RequestPlan(component); } // Getting a new plan so do nothing. if (component.Plan == null) return; // Run the existing plan still var status = HTNOperatorStatus.Finished; // Continuously run operators until we can't anymore. while (status != HTNOperatorStatus.Continuing && component.Plan != null) { // Run the existing operator var currentOperator = component.Plan.CurrentOperator; var currentTask = component.Plan.CurrentTask; var blackboard = component.Blackboard; // Service still on cooldown. if (component.CheckServices) { foreach (var service in currentTask.Services) { var serviceResult = _utility.GetEntities(blackboard, service.Prototype); blackboard.SetValue(service.Key, serviceResult.GetHighest()); } component.CheckServices = false; } status = currentOperator.Update(blackboard, frameTime); switch (status) { case HTNOperatorStatus.Continuing: break; case HTNOperatorStatus.Failed: ShutdownTask(currentOperator, blackboard, status); ShutdownPlan(component); break; // Operator completed so go to the next one. case HTNOperatorStatus.Finished: ShutdownTask(currentOperator, blackboard, status); component.Plan.Index++; // Plan finished! if (component.Plan.Tasks.Count <= component.Plan.Index) { ShutdownPlan(component); break; } ConditionalShutdown(component.Plan, currentOperator, blackboard, HTNPlanState.TaskFinished); StartupTask(component.Plan.Tasks[component.Plan.Index], component.Blackboard, component.Plan.Effects[component.Plan.Index]); break; default: throw new InvalidOperationException(); } } } public void ShutdownTask(HTNOperator currentOperator, NPCBlackboard blackboard, HTNOperatorStatus status) { if (currentOperator is IHtnConditionalShutdown conditional && (conditional.ShutdownState & HTNPlanState.TaskFinished) != 0x0) { conditional.ConditionalShutdown(blackboard); } currentOperator.TaskShutdown(blackboard, status); } public void ShutdownPlan(HTNComponent component) { DebugTools.Assert(component.Plan != null); var blackboard = component.Blackboard; foreach (var task in component.Plan.Tasks) { if (task.Operator is IHtnConditionalShutdown conditional && (conditional.ShutdownState & HTNPlanState.PlanFinished) != 0x0) { conditional.ConditionalShutdown(blackboard); } task.Operator.PlanShutdown(component.Blackboard); } component.Plan = null; } /// /// Shuts down the current operator conditionally. /// private void ConditionalShutdown(HTNPlan plan, HTNOperator currentOperator, NPCBlackboard blackboard, HTNPlanState state) { if (currentOperator is not IHtnConditionalShutdown conditional) return; if ((conditional.ShutdownState & state) == 0x0) return; conditional.ConditionalShutdown(blackboard); } /// /// Starts a new primitive task. Will apply effects from planning if applicable. /// private void StartupTask(HTNPrimitiveTask primitive, NPCBlackboard blackboard, Dictionary? effects) { // We may have planner only tasks where we want to reuse their data during update // e.g. if we pathfind to an enemy to know if we can attack it, we don't want to do another pathfind immediately if (effects != null && primitive.ApplyEffectsOnStartup) { foreach (var (key, value) in effects) { blackboard.SetValue(key, value); } } primitive.Operator.Startup(blackboard); } /// /// Request a new plan for this component, even if running an existing plan. /// /// private void RequestPlan(HTNComponent component) { if (component.PlanningJob != null) return; component.PlanAccumulator += component.PlanCooldown; var cancelToken = new CancellationTokenSource(); var branchTraversal = component.Plan?.BranchTraversalRecord; var job = new HTNPlanJob( 0.02, _prototypeManager, component.RootTask, component.Blackboard.ShallowClone(), branchTraversal, cancelToken.Token); _planQueue.EnqueueJob(job); component.PlanningJob = job; component.PlanningToken = cancelToken; } public string GetDomain(HTNCompoundTask compound) { // TODO: Recursively add each one var indent = 0; var builder = new StringBuilder(); AppendDomain(builder, compound, ref indent); return builder.ToString(); } private void AppendDomain(StringBuilder builder, HTNTask task, ref int indent) { var buffer = string.Concat(Enumerable.Repeat(" ", indent)); if (task is HTNPrimitiveTask primitive) { builder.AppendLine(buffer + $"Primitive: {task}"); builder.AppendLine(buffer + $" operator: {primitive.Operator.GetType().Name}"); } else if (task is HTNCompoundTask compTask) { var compound = _prototypeManager.Index(compTask.Task); builder.AppendLine(buffer + $"Compound: {task}"); for (var i = 0; i < compound.Branches.Count; i++) { var branch = compound.Branches[i]; builder.AppendLine(buffer + " branch:"); indent++; foreach (var branchTask in branch.Tasks) { AppendDomain(builder, branchTask, ref indent); } indent--; } } } } /// /// The outcome of the current operator during update. /// public enum HTNOperatorStatus : byte { Continuing, Failed, Finished, /// /// Was a better plan than this found? /// BetterPlan, }