-
Notifications
You must be signed in to change notification settings - Fork 52
AI Framework
The basic premise of the AI framework is that it operates by scheduling a series of tasks designed to accomplish a goal. Goals are accomplished by tasks, which are essentially modular mini-states which use completion/fail checks to determine when the task should be de-queued and control returned to the previous task. Each task is able to have one active subtask; when a task has a subtask, it passes control to it's subtask rather than check it's own cne list. Using this natural recursion, it is possible to start at a large scale task (GrindMobs) and drill down through several subtasks (KillTarget->MoveToTarget->ClassCombatRoutine). Using the natural stack-based recursion of the subtask pointers, once a task is complete control passes back to the parent task, leading to some very nice side effect:
-
Implicit state changes: If we consider the tasks as mini states, then with subtask recursion there is no need to use explicit state changes such as are seen in the GW2 system. Control will naturally pass back to the parent task which can then choose another task to queue. In the previous example, if the mob moves away from the Player during the ClassCombatRoutine, the fail() check can be thrown which will then return control back to the MoveToTarget task which can then move the Player back into combat range and add the ClassCombatRoutine subtask again.
-
Removing redundant code: In the GW2 state system, each cne must check for bad state such as nil target pointers, target out of range, etc. With the task system a single higher level task can perform this check before passing control to it's subtask, with the result that the subtask no longer has to be concerned with those details and can simply focus on accomplishing it's own goal. In the previous example, this means that the KillTarget task could check to ensure that the target is still valid and within range... If the target is not valid, it finds a new valid target, and if the target is out of range, it queues a MoveToTarget task to get within range. By the time the ClassCombatRoutine task is added, it is assured that those checks have already taken place so they are not necessary to repeat in each cne in the ClassCombatTask.
Aside from these benefits, the other major benefit of the goal driven system is that it takes the flat hierarchy of the GW2 state system and breaks up the individual components of a state into goal specific mini-states. This means that we can create lots of these modular tasks and combine them to perform custom behavior that would not be possible using a state. This also allows for static sequenced behavior; with the state system, the only way to sequence actions was to use the cne priorities...since tasks control the subtasks that they can queue, we are able to create a guaranteed sequence of actions without having to "hack" it using cne priorities.
The downside of this system is that it is more complex to understand and write basic functionality like grinding for. This is the penalty we pay for utilizing modularity to provide flexibility for more custom functionality. We have attempted to use as much inheritance and OO as possible to hide most of the boiler plate code in order to make understanding and writing new tasks easier.
LUA's pseudo object oriented progamming works by assigning metatables from one table to another and is accomplished in the framework via two mechanisms
inheritsFrom
myNewClass = inheritsFrom(myOldClass)
The inheritsFrom function assigns the metatable for myOldClass to myNewClass; this means that myNewClass now has function pointers for the functions defined in myOldClass. It does NOT inherit myOldClass data members (table values).
Create()
myNewClass = inheritsFrom(myOldClass)
myNewClass:Create()
local newinst = inheritsFrom(myNewClass)
newinst.member1 = 0
newinst.member2 = false
end
The Create() function is used to declare/init the data members for the new instance. These members are not inherited from myOldClass automatically, and must be added for the new instance to have unique references. If you do not use a Create() function to init these new data members, they will simply refer to the members in the parent class since the __index function will pass the key to the superClass when it does not find the member in it's own table.
':' vs '.'
my_class:my_function
vs my_class.my_function
The ":" is used to pass an implicit "self" pointer to the function when it is called, and must be used when the function refers to the object instance. For example, this function would fail at runtime:
my_class.myfunction()
self.value = 4
end
Since the "." notation is used, the self pointer does not exist. You should always use ":" when the function will reference instance object in any way. On the same note, when dealing with a non static class (which tasks/queues/etc are) you should use the "self" pointer to refer to the table values when necessary rather than the class name (ffxiv_combat_lancer, for example). If you use the class name, you will be accessing the variables of the static table rather than the instance.
ml_task_hub
The task hub is the controller for the framework. It's main job is to find the highest priority queue (of which 3 exist) that contains a currently active task and pass the update pulse to it. It's also responsible for adding new tasks to queues and promoting queued tasks from pending to active. It contains convenience functions for returning the current queue/task which are used by the tasks/cnes to get state information since the tasks/cnes have no information linking them to their "owner" object and thus must use this mechanism to get data such as targetid, position, etc.
ml_task_queue
The task queue(s) contain a rootTask and pendingTask and are responsible for passing the update to the rootTask and providing accessors for the task information which are used by the task hub to control the task flow. There's not much to understand about the task queue and you can mostly ignore it.
ml_task
The task is the most complex object in the framework and represents the core behavior module. It contains data members and functions for determining the current state of the task, querying the game state, and making decisions that queue player actions. It accomplishes this via the Update(), Process(), and ProcessOverWatch() functions which will be discussed in detail below. It also defines oo-convenience functions to remove boilerplate code from the inherited tasks, such as code to add the complete/fail elements to the lists, add and delete subtasks, etc. Any new function or data member which would apply to all tasks should go here so that it can be inherited properly.
The ml_task object contains two lists of elements, process_elements and overwatch_elements. These lists contain the set of cne objects which will be evaluated each time the Process() and ProcessOverWatch() functions for the task are called. This mechanism allows each task to be used like a mini-state.
Update()
The Update() function is responsible for passing each pulse to the lowest level current task via the subtask mechanism and calling it's ProcessOverWatch() and Process() functions.
function ml_task:Update()
local continueUpdate = true
while (continueUpdate) do
if (not self:isValid()) then
self:DeleteSubTasks()
return TS_FAILED
end
if ( self:hasCompleted() ) then
self:DeleteSubTasks()
return TS_SUCCEEDED
end
local taskRet = nil
if(self:ProcessOverWatch()) then
break
end
if ( self.subtask ~= nil ) then
taskRet = self.subtask:Update()
if ( taskRet ~= TS_PROGRESSING ) then
continueUpdate = self:OnSubTaskReturn(ret)
self:DeleteSubTasks()
else
continueUpdate = false
end
else
continueUpdate = self:Process()
end
end
return TS_PROGRESSING
end
The first step in the Update() function is to check whether the current task has had it's fail flag set. This would be done using the fail checks ml_task:task_fail_eval()/ml_task:task_fail_execute() if a task had failed. I haven't seen much use for task failure yet at this point.
The second step is to check whether the current task has set it's complete flag. This is the mechanism used to pop a task from the stack and return to the previous task. For this reason, every task must have a working implementation of TASK_COMPLETE_EVAL() and TASK_COMPLETE_EXECUTE(). Simply define these ce objects and functions in your task and the AddTaskCheckCEs() function add them to the task instance process_elements list when it is added to the stack.
The third step is to call ProcessOverWatch(), which will use the cne engine in ml_cne_hub to evaluate every element in the overwatch_elements list and queue a single effect for execution. Since this function is called BEFORE checking for subtasks or calling the Process() function, it should be used for emergency task changes such as target moved out of range, aggro from another entity, target lost, etc. If the ProcessOverWatch() function executes an effect, it will return true, and the Update() function will break out of the loop before calling subtask or Process() function and return control to the task hub. This is due to the fact that most of the ProcessOverWatch element effects should queue a higher priority task such as MoveToTarget, KillTarget, etc, so we want to return to the task hub so that it can pass control to the higher priority queue rather than continue execution of our current queue.
The fourth step is to check if a subtask exists and, if so, pass the Update() pulse to the subtask. This is the mechanism used to execute the Process() function of the lowest level subtask. Note that this means that ONLY the process function of the lowest level task will be called, not any of it's parent tasks. Therefore the ProcessOverWatch element list should be used for any cnes that you want parent tasks to check every pulse, not the Process element list.
The final step is to call the Process() function; this step will only be reached is self.subtask == nil. The Process() function is discussed in further detail below. There are also other subtleties here such as return values (TS_FAILED vs TS_PROGRESSING etc) but these can be easily understood by studying the code.
ProcessOverWatch()
The ProcessOverWatch() function is simple...it checks a list of cne elements and queues an effect if appropriate.
function ml_task:ProcessOverWatch()
if (TableSize(self.overwatch_elements) > 0) then
ml_cne_hub.clear_queue()
ml_cne_hub.eval_elements(self.overwatch_elements)
ml_cne_hub.queue_to_execute()
return ml_cne_hub.execute()
end
end
This function is your mechanism for doing emergency checks on the game state data that the current task or subtask is using and should be utilized to remove redundant checks and perform high level reactive decision making. Two examples of checks that should be done primarily in ProcessOverWatch() are NewAggro and NoTarget. Generally, if the ProcessOverWatch() effect that is executed adds a new task (such as KillTarget for NewAggro) this task will go into a higher priority queue (the REACTIVE_GOAL or IMMEDIATE_GOAL queue).
Process()
The Process() functions is essentially the same as the ProcessOverWatch() function.
function ml_task:Process()
if (TableSize(self.process_elements) > 0) then
ml_cne_hub.clear_queue()
ml_cne_hub.eval_elements(self.process_elements)
ml_cne_hub.queue_to_execute()
ml_cne_hub.execute()
return false
end
The cne elements in the process_elements list should be those that are specific to the current task. A combat routine task should have cnes for determining the optimal skill to cast, whereas a MoveToPos task should have cnes for checking it's current position vs the desired position etc. General game state check cnes should NOT be in the process_elements list and should instead be placed in the overwatch_elements list so that they can be handled by ProcessOverWatch() before Process() is called. This removes lots and lots of redundant safety checks in the Process() cnes and should always be considered when adding a new task. One other important difference here is that Process() always returns false. In the Update() function, the return value of Process() is used to determine whether to break out of the current loop; since we want to always end a pulse after an effect has been executed, we simply return false to force the Update() loop to break and return control to the task hub for the next pulse.