using System.Linq; using System.Threading; using System.Threading.Tasks; using Content.Server.CPUJob.JobQueues; using Content.Server.NPC.HTN.PrimitiveTasks; namespace Content.Server.NPC.HTN; /// /// A time-sliced job that will retrieve an HTN plan eventually. /// public sealed class HTNPlanJob : Job { private readonly HTNCompoundTask _rootTask; private NPCBlackboard _blackboard; /// /// Branch traversal of an existing plan (if applicable). /// private List? _branchTraversal; public HTNPlanJob( double maxTime, HTNCompoundTask rootTask, NPCBlackboard blackboard, List? branchTraversal, CancellationToken cancellationToken = default) : base(maxTime, cancellationToken) { _rootTask = rootTask; _blackboard = blackboard; _branchTraversal = branchTraversal; } protected override async Task Process() { /* * Really the best reference for what a HTN looks like is http://www.gameaipro.com/GameAIPro/GameAIPro_Chapter12_Exploring_HTN_Planners_through_Example.pdf * It's kinda like a behaviour tree but also can consider multiple actions in sequence. * * Methods have been renamed to branches */ var decompHistory = new Stack(); // branch traversal record. Whenever we find a new compound task this updates. var btrIndex = 0; var btr = new List(); // For some tasks we may do something expensive or want to re-use the planning result. // e.g. pathfind to a target before deciding to attack it. // Given all of the primitive tasks are singletons we need to store the data somewhere // hence we'll store it here. var appliedStates = new List?>(); var tasksToProcess = new Queue(); var finalPlan = new List(); tasksToProcess.Enqueue(_rootTask); // How many primitive tasks we've added since last record. var primitiveCount = 0; while (tasksToProcess.TryDequeue(out var currentTask)) { switch (currentTask) { case HTNCompoundTask compound: await SuspendIfOutOfTime(); if (TryFindSatisfiedMethod(compound, tasksToProcess, _blackboard, ref btrIndex)) { // Need to copy worldstate to roll it back // Don't need to copy taskstoprocess as we can just clear it and set it to the compound task we roll back to. // Don't need to copy finalplan as we can just count how many primitives we've added since last record decompHistory.Push(new DecompositionState() { Blackboard = _blackboard.ShallowClone(), CompoundTask = compound, BranchTraversal = btrIndex, PrimitiveCount = primitiveCount, }); btr.Add(btrIndex); // TODO: Early out if existing plan is better and save lots of time. // my brain is not working rn AAA primitiveCount = 0; // Reset method traversal btrIndex = 0; } else { RestoreTolastDecomposedTask(decompHistory, tasksToProcess, appliedStates, finalPlan, ref primitiveCount, ref _blackboard, ref btrIndex, ref btr); } break; case HTNPrimitiveTask primitive: if (await WaitAsyncTask(PrimitiveConditionMet(primitive, _blackboard, appliedStates))) { primitiveCount++; finalPlan.Add(primitive); } else { RestoreTolastDecomposedTask(decompHistory, tasksToProcess, appliedStates, finalPlan, ref primitiveCount, ref _blackboard, ref btrIndex, ref btr); } break; } } if (finalPlan.Count == 0) { return null; } var branchTraversalRecord = decompHistory.Reverse().Select(o => o.BranchTraversal).ToList(); return new HTNPlan(finalPlan, branchTraversalRecord, appliedStates); } private async Task PrimitiveConditionMet(HTNPrimitiveTask primitive, NPCBlackboard blackboard, List?> appliedStates) { blackboard.ReadOnly = true; foreach (var con in primitive.Preconditions) { if (con.IsMet(blackboard)) continue; return false; } var (valid, effects) = await primitive.Operator.Plan(blackboard, Cancellation); if (!valid) return false; blackboard.ReadOnly = false; if (effects != null) { foreach (var (key, value) in effects) { blackboard.SetValue(key, value); } } appliedStates.Add(effects); return true; } /// /// Goes through each compound task branch and tries to find an appropriate one. /// private bool TryFindSatisfiedMethod(HTNCompoundTask compound, Queue tasksToProcess, NPCBlackboard blackboard, ref int mtrIndex) { for (var i = mtrIndex; i < compound.Branches.Count; i++) { var branch = compound.Branches[i]; var isValid = true; foreach (var con in branch.Preconditions) { if (con.IsMet(blackboard)) continue; isValid = false; break; } if (!isValid) continue; foreach (var task in branch.Tasks) { tasksToProcess.Enqueue(task); } return true; } return false; } /// /// Restores the planner state. /// private void RestoreTolastDecomposedTask( Stack decompHistory, Queue tasksToProcess, List?> appliedStates, List finalPlan, ref int primitiveCount, ref NPCBlackboard blackboard, ref int mtrIndex, ref List btr) { tasksToProcess.Clear(); // No plan found so this will just break normally. if (!decompHistory.TryPop(out var lastDecomp)) return; // Increment MTR so next time we try the next method on the compound task. mtrIndex = lastDecomp.BranchTraversal + 1; var count = finalPlan.Count; // Final plan only has primitive tasks added to it so we can just remove the count we've tracked since the last decomp. finalPlan.RemoveRange(count - primitiveCount, primitiveCount); appliedStates.RemoveRange(count - primitiveCount, primitiveCount); btr.RemoveRange(count - primitiveCount, primitiveCount); primitiveCount = lastDecomp.PrimitiveCount; blackboard = lastDecomp.Blackboard; tasksToProcess.Enqueue(lastDecomp.CompoundTask); } /// /// Stores the state of an HTN Plan while planning it. This is so we can rollback if a particular branch is unsuitable. /// private sealed class DecompositionState { /// /// Blackboard as at decomposition. /// public NPCBlackboard Blackboard = default!; /// /// How many primitive tasks we've added since last decompositionstate. /// public int PrimitiveCount; /// /// The compound task that owns this decomposition. /// public HTNCompoundTask CompoundTask = default!; // This may not be necessary for planning but may be useful for debugging so I didn't remove it. /// /// Which branch (AKA method) we took of the compound task. Whenever we rollback the decomposition state /// this gets incremented by 1 so we check the next method. /// public int BranchTraversal; } }