From 81b3a201304f8d23e50f4cf43d9696a2493e4207 Mon Sep 17 00:00:00 2001 From: warriorstar-orion Date: Wed, 12 Feb 2025 17:16:06 -0500 Subject: [PATCH] /tg/ AI controllers, part 1: core implementation. (#28065) * /tg/ AI controllers, part 1: core implementation. * lewc review * remove unused arg * lewc review 2 * lint fix --- code/__DEFINES/ai/ai_defines.dm | 60 ++ code/__DEFINES/ai/blackboard_defines.dm | 4 + code/__DEFINES/dcs/ai_signals.dm | 19 + code/__DEFINES/subsystems.dm | 28 +- code/__HELPERS/trait_helpers.dm | 3 + code/_onclick/click.dm | 4 +- .../controllers/subsystem/SSai_controllers.dm | 65 ++ .../subsystem/movement/SSai_movement.dm | 19 + .../subsystem/processing/SSai_behaviors.dm | 31 + code/datums/ai/ai_behavior.dm | 63 ++ code/datums/ai/ai_controller.dm | 761 ++++++++++++++++++ code/datums/ai/ai_planning_subtree.dm | 11 + .../datums/ai/idle_behaviors/idle_behavior.dm | 4 + code/datums/ai/movement/ai_movement.dm | 75 ++ code/datums/ai/targeting_strategy.dm | 15 + code/game/atoms.dm | 15 + code/game/atoms_movable.dm | 36 + code/modules/mob/mob_login_base.dm | 3 + code/modules/mob/mob_logout_base.dm | 1 + .../space_management/zlevel_manager.dm | 1 + docs/references/ai_controllers.md | 269 +++++++ mkdocs.yml | 1 + paradise.dme | 12 + 23 files changed, 1486 insertions(+), 14 deletions(-) create mode 100644 code/__DEFINES/ai/ai_defines.dm create mode 100644 code/__DEFINES/ai/blackboard_defines.dm create mode 100644 code/__DEFINES/dcs/ai_signals.dm create mode 100644 code/controllers/subsystem/SSai_controllers.dm create mode 100644 code/controllers/subsystem/movement/SSai_movement.dm create mode 100644 code/controllers/subsystem/processing/SSai_behaviors.dm create mode 100644 code/datums/ai/ai_behavior.dm create mode 100644 code/datums/ai/ai_controller.dm create mode 100644 code/datums/ai/ai_planning_subtree.dm create mode 100644 code/datums/ai/idle_behaviors/idle_behavior.dm create mode 100644 code/datums/ai/movement/ai_movement.dm create mode 100644 code/datums/ai/targeting_strategy.dm create mode 100644 docs/references/ai_controllers.md diff --git a/code/__DEFINES/ai/ai_defines.dm b/code/__DEFINES/ai/ai_defines.dm new file mode 100644 index 000000000000..8ba7fec642c8 --- /dev/null +++ b/code/__DEFINES/ai/ai_defines.dm @@ -0,0 +1,60 @@ +#define GET_AI_BEHAVIOR(behavior_type) SSai_behaviors.ai_behaviors[behavior_type] +#define GET_TARGETING_STRATEGY(targeting_type) SSai_behaviors.targeting_strategies[targeting_type] +#define HAS_AI_CONTROLLER_TYPE(thing, type) istype(thing?.ai_controller, type) + +//AI controller flags +//If you add a new status, be sure to add it to the ai_controllers subsystem's ai_controllers_by_status list. +/// The AI is currently active. +#define AI_STATUS_ON "ai_on" +/// The AI is currently offline for any reason. +#define AI_STATUS_OFF "ai_off" +/// The AI is currently in idle mode. +#define AI_STATUS_IDLE "ai_idle" + +// How far should we, by default, be looking for interesting things to de-idle? +#define AI_DEFAULT_INTERESTING_DIST 10 + +/// Cooldown on planning if planning failed last time + +#define AI_FAILED_PLANNING_COOLDOWN (1.5 SECONDS) + +/// Flags for ai_behavior new() +#define AI_CONTROLLER_INCOMPATIBLE (1<<0) + +// Return flags for ai_behavior/perform() + +/// Update this behavior's cooldown +#define AI_BEHAVIOR_DELAY (1<<0) +/// Finish the behavior successfully +#define AI_BEHAVIOR_SUCCEEDED (1<<1) +/// Finish the behavior unsuccessfully +#define AI_BEHAVIOR_FAILED (1<<2) + +#define AI_BEHAVIOR_INSTANT (NONE) + +/// Does this task require movement from the AI before it can be performed? +#define AI_BEHAVIOR_REQUIRE_MOVEMENT (1<<0) +/// Does this require the current_movement_target to be adjacent and in reach? +#define AI_BEHAVIOR_REQUIRE_REACH (1<<1) +/// Does this task let you perform the action while you move closer? (Things like moving and shooting) +#define AI_BEHAVIOR_MOVE_AND_PERFORM (1<<2) +/// Does finishing this task not null the current movement target? +#define AI_BEHAVIOR_KEEP_MOVE_TARGET_ON_FINISH (1<<3) +/// Does this behavior NOT block planning? +#define AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION (1<<4) + +// AI flags + +/// Don't move if being pulled +#define AI_FLAG_STOP_MOVING_WHEN_PULLED (1<<0) +/// Continue processing even if dead +#define AI_FLAG_CAN_ACT_WHILE_DEAD (1<<1) +/// Stop processing while in a progress bar +#define AI_FLAG_PAUSE_DURING_DO_AFTER (1<<2) +/// Continue processing while in stasis +#define AI_FLAG_CAN_ACT_IN_STASIS (1<<3) + +// Base Subtree defines + +/// This subtree should cancel any further planning, (Including from other subtrees) +#define SUBTREE_RETURN_FINISH_PLANNING 1 diff --git a/code/__DEFINES/ai/blackboard_defines.dm b/code/__DEFINES/ai/blackboard_defines.dm new file mode 100644 index 000000000000..47d18482e0b9 --- /dev/null +++ b/code/__DEFINES/ai/blackboard_defines.dm @@ -0,0 +1,4 @@ +// Generic blackboard keys. +// This file will get a lot larger as AI subtrees are added. + +#define BB_CURRENT_MIN_MOVE_DISTANCE "min_move_distance" diff --git a/code/__DEFINES/dcs/ai_signals.dm b/code/__DEFINES/dcs/ai_signals.dm new file mode 100644 index 000000000000..b6a3ce5e3999 --- /dev/null +++ b/code/__DEFINES/dcs/ai_signals.dm @@ -0,0 +1,19 @@ +///sent from ai controllers when a behavior is inserted into the queue: (list/new_arguments) +#define AI_CONTROLLER_BEHAVIOR_QUEUED(type) "ai_controller_behavior_queued_[type]" + +/// Signal sent when a blackboard key is set to a new value +#define COMSIG_AI_BLACKBOARD_KEY_SET(blackboard_key) "ai_blackboard_key_set_[blackboard_key]" + +///Signal sent before a blackboard key is cleared +#define COMSIG_AI_BLACKBOARD_KEY_PRECLEAR(blackboard_key) "ai_blackboard_key_pre_clear_[blackboard_key]" + +/// Signal sent when a blackboard key is cleared +#define COMSIG_AI_BLACKBOARD_KEY_CLEARED(blackboard_key) "ai_blackboard_key_clear_[blackboard_key]" + +//from base of atom/attack_basic_mob(): (/mob/user) +#define COMSIG_ATOM_ATTACK_BASIC_MOB "attack_basic_mob" + +///sent from ai controllers when they possess a pawn: (datum/ai_controller/source_controller) +#define COMSIG_AI_CONTROLLER_POSSESSED_PAWN "ai_controller_possessed_pawn" +///sent from ai controllers when they pick behaviors: (list/datum/ai_behavior/old_behaviors, list/datum/ai_behavior/new_behaviors) +#define COMSIG_AI_CONTROLLER_PICKED_BEHAVIORS "ai_controller_picked_behaviors" diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 61856621d612..740b2276fcad 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -50,18 +50,20 @@ #define INIT_ORDER_PROFILER 101 #define INIT_ORDER_QUEUE 100 // Load this quickly so people cant queue skip #define INIT_ORDER_TITLE 99 // Load this quickly so people dont see a blank lobby screen -#define INIT_ORDER_GARBAGE 22 -#define INIT_ORDER_DBCORE 21 -#define INIT_ORDER_REDIS 20 // Make sure we dont miss any events -#define INIT_ORDER_BLACKBOX 19 -#define INIT_ORDER_CLEANUP 18 -#define INIT_ORDER_INPUT 17 -#define INIT_ORDER_SOUNDS 16 -#define INIT_ORDER_INSTRUMENTS 15 -#define INIT_ORDER_RESEARCH 14 // SoonTM -#define INIT_ORDER_STATION 13 //This is high priority because it manipulates a lot of the subsystems that will initialize after it. -#define INIT_ORDER_EVENTS 12 -#define INIT_ORDER_JOBS 11 +#define INIT_ORDER_GARBAGE 24 +#define INIT_ORDER_DBCORE 23 +#define INIT_ORDER_REDIS 22 // Make sure we dont miss any events +#define INIT_ORDER_BLACKBOX 21 +#define INIT_ORDER_CLEANUP 20 +#define INIT_ORDER_INPUT 19 +#define INIT_ORDER_SOUNDS 18 +#define INIT_ORDER_INSTRUMENTS 17 +#define INIT_ORDER_RESEARCH 16 // SoonTM +#define INIT_ORDER_STATION 15 //This is high priority because it manipulates a lot of the subsystems that will initialize after it. +#define INIT_ORDER_EVENTS 14 +#define INIT_ORDER_JOBS 13 +#define INIT_ORDER_AI_MOVEMENT 12 +#define INIT_ORDER_AI_CONTROLLERS 11 #define INIT_ORDER_TICKER 10 #define INIT_ORDER_MAPPING 9 #define INIT_ORDER_EARLY_ASSETS 8 @@ -102,6 +104,8 @@ #define FIRE_PRIORITY_AIR 20 #define FIRE_PRIORITY_NPC 20 #define FIRE_PRIORITY_CAMERA 20 +#define FIRE_PRIORITY_NPC_MOVEMENT 21 +#define FIRE_PRIORITY_NPC_ACTIONS 22 #define FIRE_PRIORITY_PATHFINDING 23 #define FIRE_PRIORITY_PROCESS 25 #define FIRE_PRIORITY_THROWING 25 diff --git a/code/__HELPERS/trait_helpers.dm b/code/__HELPERS/trait_helpers.dm index e41cb2713498..efecfbbce5e5 100644 --- a/code/__HELPERS/trait_helpers.dm +++ b/code/__HELPERS/trait_helpers.dm @@ -255,6 +255,9 @@ Remember to update _globalvars/traits.dm if you're adding/removing/renaming trai #define TRAIT_CRYO_DESPAWNING "cryo_despawning" // dont adminbus this please #define TRAIT_EXAMINE_HALLUCINATING "examine_hallucinating" +/// Trait that prevents AI controllers from planning detached from ai_status to prevent weird state stuff. +#define TRAIT_AI_PAUSED "trait_ai_paused" + //***** MIND TRAITS *****/ #define TRAIT_HOLY "is_holy" // The mob is holy in regards to religion #define TRAIT_TABLE_LEAP "table_leap" // Lets bartender and chef mount tables faster diff --git a/code/_onclick/click.dm b/code/_onclick/click.dm index c108b54e38cf..0363cad9e2ee 100644 --- a/code/_onclick/click.dm +++ b/code/_onclick/click.dm @@ -183,7 +183,7 @@ continue if(isturf(target) || isturf(target.loc) || (target in direct_access) || !(target.IsObscured()) || istype(target.loc, /obj/item/storage)) //Directly accessible atoms - if(target.Adjacent(src) || (tool && CheckToolReach(src, target, tool.reach))) //Adjacent or reaching attacks + if(target.Adjacent(src) || (tool && check_tool_reach(src, target, tool.reach))) //Adjacent or reaching attacks return TRUE closed[target] = TRUE @@ -206,7 +206,7 @@ /mob/living/direct_access(atom/target) return ..() + get_contents() -/proc/CheckToolReach(atom/movable/here, atom/movable/there, reach) +/proc/check_tool_reach(atom/movable/here, atom/movable/there, reach) if(!here || !there) return FALSE switch(reach) diff --git a/code/controllers/subsystem/SSai_controllers.dm b/code/controllers/subsystem/SSai_controllers.dm new file mode 100644 index 000000000000..424ece3607e8 --- /dev/null +++ b/code/controllers/subsystem/SSai_controllers.dm @@ -0,0 +1,65 @@ +/// The subsystem used to tick [/datum/ai_controllers] instances. Handling the re-checking of plans. +SUBSYSTEM_DEF(ai_controllers) + name = "AI Controller Ticker" + flags = SS_POST_FIRE_TIMING|SS_BACKGROUND + priority = FIRE_PRIORITY_NPC + init_order = INIT_ORDER_AI_CONTROLLERS + wait = 0.5 SECONDS //Plan every half second if required, not great not terrible. + runlevels = RUNLEVEL_GAME | RUNLEVEL_POSTGAME + + ///List of all ai_subtree singletons, key is the typepath while assigned value is a newly created instance of the typepath. See setup_subtrees() + var/list/datum/ai_planning_subtree/ai_subtrees = list() + ///Assoc List of all AI statuses and all AI controllers with that status. + var/list/ai_controllers_by_status = list( + AI_STATUS_ON = list(), + AI_STATUS_OFF = list(), + AI_STATUS_IDLE = list(), + ) + ///Assoc List of all AI controllers and the Z level they are on, which we check when someone enters/leaves a Z level to turn them on/off. + var/list/ai_controllers_by_zlevel = list() + /// The tick cost of all active AI, calculated on fire. + var/cost_on + /// The tick cost of all idle AI, calculated on fire. + var/cost_idle + + +/datum/controller/subsystem/ai_controllers/Initialize() + setup_subtrees() + +/datum/controller/subsystem/ai_controllers/stat_entry(msg) + var/list/active_list = ai_controllers_by_status[AI_STATUS_ON] + var/list/inactive_list = ai_controllers_by_status[AI_STATUS_OFF] + var/list/idle_list = ai_controllers_by_status[AI_STATUS_IDLE] + msg = "Active AIs:[length(active_list)]/[round(cost_on,1)]%|Inactive:[length(inactive_list)]|Idle:[length(idle_list)]/[round(cost_idle,1)]%" + return ..() + +/datum/controller/subsystem/ai_controllers/fire(resumed) + var/timer = TICK_USAGE_REAL + cost_idle = MC_AVERAGE(cost_idle, TICK_DELTA_TO_MS(TICK_USAGE_REAL - timer)) + + timer = TICK_USAGE_REAL + for(var/datum/ai_controller/ai_controller as anything in ai_controllers_by_status[AI_STATUS_ON]) + if(!COOLDOWN_FINISHED(ai_controller, failed_planning_cooldown)) + continue + + if(!ai_controller.able_to_plan()) + continue + ai_controller.select_behaviors(wait / (1 SECONDS)) + if(!LAZYLEN(ai_controller.current_behaviors)) //Still no plan + COOLDOWN_START(ai_controller, failed_planning_cooldown, AI_FAILED_PLANNING_COOLDOWN) + + cost_on = MC_AVERAGE(cost_on, TICK_DELTA_TO_MS(TICK_USAGE_REAL - timer)) + +///Creates all instances of ai_subtrees and assigns them to the ai_subtrees list. +/datum/controller/subsystem/ai_controllers/proc/setup_subtrees() + for(var/subtree_type in subtypesof(/datum/ai_planning_subtree)) + var/datum/ai_planning_subtree/subtree = new subtree_type + ai_subtrees[subtree_type] = subtree + +///Called when the max Z level was changed, updating our coverage. +/datum/controller/subsystem/ai_controllers/proc/on_max_z_changed() + if(!islist(ai_controllers_by_zlevel)) + ai_controllers_by_zlevel = new /list(world.maxz, 0) + while(SSai_controllers.ai_controllers_by_zlevel.len < world.maxz) + SSai_controllers.ai_controllers_by_zlevel.len++ + SSai_controllers.ai_controllers_by_zlevel[ai_controllers_by_zlevel.len] = list() diff --git a/code/controllers/subsystem/movement/SSai_movement.dm b/code/controllers/subsystem/movement/SSai_movement.dm new file mode 100644 index 000000000000..c71f44dba3c1 --- /dev/null +++ b/code/controllers/subsystem/movement/SSai_movement.dm @@ -0,0 +1,19 @@ +/// The subsystem used to tick [/datum/ai_movement] instances. Handling the movement of individual AI instances +MOVEMENT_SUBSYSTEM_DEF(ai_movement) + name = "AI movement" + flags = SS_BACKGROUND|SS_TICKER + priority = FIRE_PRIORITY_NPC_MOVEMENT + runlevels = RUNLEVEL_GAME | RUNLEVEL_POSTGAME + init_order = INIT_ORDER_AI_MOVEMENT + + ///an assoc list of all ai_movement types. Assoc type to instance + var/list/movement_types + +/datum/controller/subsystem/movement/ai_movement/Initialize() + setup_ai_movement_instances() + +/datum/controller/subsystem/movement/ai_movement/proc/setup_ai_movement_instances() + movement_types = list() + for(var/key as anything in subtypesof(/datum/ai_movement)) + var/datum/ai_movement/ai_movement = new key + movement_types[key] = ai_movement diff --git a/code/controllers/subsystem/processing/SSai_behaviors.dm b/code/controllers/subsystem/processing/SSai_behaviors.dm new file mode 100644 index 000000000000..f0038464903f --- /dev/null +++ b/code/controllers/subsystem/processing/SSai_behaviors.dm @@ -0,0 +1,31 @@ +/// The subsystem used to tick [/datum/ai_behavior] instances. +/// Handling the individual actions an AI can take like punching someone in the fucking NUTS +PROCESSING_SUBSYSTEM_DEF(ai_behaviors) + name = "AI Behavior Ticker" + flags = SS_POST_FIRE_TIMING|SS_BACKGROUND + priority = FIRE_PRIORITY_NPC_ACTIONS + runlevels = RUNLEVEL_GAME|RUNLEVEL_POSTGAME + init_order = INIT_ORDER_AI_CONTROLLERS + wait = 1 + /// List of all ai_behavior singletons, key is the typepath while assigned + /// value is a newly created instance of the typepath. See setup_ai_behaviors(). + var/list/ai_behaviors + /// List of all targeting_strategy singletons, key is the typepath while assigned + /// value is a newly created instance of the typepath. See setup_targeting_strats(). + var/list/targeting_strategies + +/datum/controller/subsystem/processing/ai_behaviors/Initialize() + setup_ai_behaviors() + setup_targeting_strats() + +/datum/controller/subsystem/processing/ai_behaviors/proc/setup_ai_behaviors() + ai_behaviors = list() + for(var/behavior_type in subtypesof(/datum/ai_behavior)) + var/datum/ai_behavior/ai_behavior = new behavior_type + ai_behaviors[behavior_type] = ai_behavior + +/datum/controller/subsystem/processing/ai_behaviors/proc/setup_targeting_strats() + targeting_strategies = list() + for(var/target_type in subtypesof(/datum/targeting_strategy)) + var/datum/targeting_strategy/target_start = new target_type + targeting_strategies[target_type] = target_start diff --git a/code/datums/ai/ai_behavior.dm b/code/datums/ai/ai_behavior.dm new file mode 100644 index 000000000000..567f3b836bb6 --- /dev/null +++ b/code/datums/ai/ai_behavior.dm @@ -0,0 +1,63 @@ +/// Abstract class for an action an AI can take. Can range from movement to grabbing a nearby weapon. +/datum/ai_behavior + /// What distance you need to be from the target to perform the action. + var/required_distance = 1 + /// Flags for extra behavior + var/behavior_flags = NONE + /// Cooldown between actions performances, defaults to the value of + /// CLICK_CD_MELEE because that seemed like a nice standard for the speed of + /// AI behavior + var/action_cooldown = CLICK_CD_MELEE + +/// Called by the AI controller when first being added. Additional arguments +/// depend on the behavior type. For example, if the behavior involves attacking +/// a mob, you may require an argument naming the blackboard key which points to +/// the target. Return FALSE to cancel. +/datum/ai_behavior/proc/setup(datum/ai_controller/controller, ...) + return TRUE + +/// Returns the delay to use for this behavior in the moment. The default +/// behavior cooldown is `CLICK_CD_MELEE`, but can be customized; for example, +/// you may want a mob crawling through vents to move slowly and at a random +/// pace between pipes. +/datum/ai_behavior/proc/get_cooldown(datum/ai_controller/cooldown_for) + return action_cooldown + +/// Called by the AI controller when this action is performed. This will +/// typically require consulting the blackboard for information on the specific +/// actions desired from this behavior, by passing the relevant blackboard data +/// keys to this proc. Returns a combination of [AI_BEHAVIOR_DELAY] or +/// [AI_BEHAVIOR_INSTANT], determining whether or not a cooldown occurs, and +/// [AI_BEHAVIOR_SUCCEEDED] or [AI_BEHAVIOR_FAILED]. The behavior's +/// `finish_action` proc is given TRUE or FALSE depending on whether or not the +/// return value of `perform` is marked as successful or unsuccessful. +/datum/ai_behavior/proc/perform(seconds_per_tick, datum/ai_controller/controller, ...) + controller.behavior_cooldowns[src] = world.time + action_cooldown + +/// Called when the action is finished. This needs the same args as `perform` +/// besides the default ones. This should be used to clear up the blackboard of +/// any unnecessary or obsolete data, and update the state of the pawn if +/// necessary once we know whether or not the AI action was successful. +/// `succeeded` is `TRUE` or `FALSE` depending on whether +/// [/datum/ai_behavior/proc/perform] returns [AI_BEHAVIOR_SUCCEEDED] or +/// [AI_BEHAVIOR_FAILED]. +/datum/ai_behavior/proc/finish_action(datum/ai_controller/controller, succeeded, ...) + LAZYREMOVE(controller.current_behaviors, src) + controller.behavior_args -= type + // If this was a movement task, reset our movement target if necessary + if(!(behavior_flags & AI_BEHAVIOR_REQUIRE_MOVEMENT)) + return + if(behavior_flags & AI_BEHAVIOR_KEEP_MOVE_TARGET_ON_FINISH) + return + clear_movement_target(controller) + controller.ai_movement.stop_moving_towards(controller) + +/// Helper proc to ensure consistency in setting the source of the movement target +/datum/ai_behavior/proc/set_movement_target(datum/ai_controller/controller, atom/target, datum/ai_movement/new_movement) + controller.set_movement_target(type, target, new_movement) + +/// Clear the controller's movement target only if it was us who last set it +/datum/ai_behavior/proc/clear_movement_target(datum/ai_controller/controller) + if(controller.movement_target_source != type) + return + controller.set_movement_target(type, null) diff --git a/code/datums/ai/ai_controller.dm b/code/datums/ai/ai_controller.dm new file mode 100644 index 000000000000..38cc060c3128 --- /dev/null +++ b/code/datums/ai/ai_controller.dm @@ -0,0 +1,761 @@ +RESTRICT_TYPE(/datum/ai_controller) + +/** + * AI controllers are a datumized form of AI that simulates the input a player + * would otherwise give to a atom. What this means is that these datums have + * ways of interacting with a specific atom and control it. They posses a + * "blackboard" with the information the AI knows and has, and will plan actions + * it will try to perform through multiple modular subtrees with behaviors. +*/ +/datum/ai_controller + /// The atom this controller is controlling. + var/atom/pawn + + /** + * This is a list of variables the AI uses and can be mutated by actions. + * + * When an action is performed you pass this list and any relevant keys for + * the variables it can mutate. + * + * DO NOT set values in the blackboard directly, and especially not if + * you're adding a datum reference to this! Use the setters, this is + * important for reference handing. + */ + var/list/blackboard = list() + + /// Bitfield of traits for this AI to handle extra behavior. + var/ai_traits = NONE + /// Current actions planned to be performed by the AI in the upcoming plan. + var/list/planned_behaviors + /// Current actions being performed by the AI. + var/list/current_behaviors + /// Current actions and their respective last time ran as an assoc list. + var/list/behavior_cooldowns = list() + /// Current status of AI (OFF/ON) + var/ai_status + /// Current movement target of the AI, generally set by decision making. + var/atom/current_movement_target + /// Identifier for what last touched our movement target, so it can be cleared conditionally + var/movement_target_source + /// Stored arguments for behaviors given during their initial creation + var/list/behavior_args = list() + /// Tracks recent pathing attempts, if we fail too many in a row we fail our current plans. + var/consecutive_pathing_attempts + /// Can the AI remain in control if there is a client? + var/continue_processing_when_client = FALSE + /// Distance to give up on target. + var/max_target_distance = 14 + /// Cooldown for new plans, to prevent AI from going nuts if it can't think of new plans and looping on end + COOLDOWN_DECLARE(failed_planning_cooldown) + /// All subtrees this AI has available. Will run them in order, so make sure + /// they're in the order you want them to run. On initialization of this + /// type, it will start as a typepath(s) and get converted to references of + /// ai_subtrees found in SSai_controllers when init_subtrees() is called + var/list/planning_subtrees + + /// The idle behavior this AI performs when it has no actions. + var/datum/idle_behavior/idle_behavior = null + + // Movement related things here + /// Reference to the movement datum we use. Is a type on initialize but becomes a ref afterwards. + var/datum/ai_movement/ai_movement + /// Delay between movements. This is on the controller so we can keep the movement datum singleton + var/movement_delay = 0.2 SECONDS + + // TODO: Move the variables below into the blackboard at some point. + + /// AI paused time + var/paused_until = 0 + /// Can this AI idle? + var/can_idle = TRUE + /// What distance should we be checking for interesting things when considering idling/deidling? Defaults to AI_DEFAULT_INTERESTING_DIST + var/interesting_dist = AI_DEFAULT_INTERESTING_DIST + +/datum/ai_controller/New(atom/new_pawn) + change_ai_movement_type(ai_movement) + init_subtrees() + + if(idle_behavior) + idle_behavior = new idle_behavior() + + if(!isnull(new_pawn)) // unit tests need the ai_controller to exist in isolation due to list schenanigans i hate it here + possess_pawn(new_pawn) + +/datum/ai_controller/Destroy(force) + set_ai_status(AI_STATUS_OFF) + unpossess_pawn(FALSE) + set_movement_target(type, null) + if(ai_movement.moving_controllers[src]) + ai_movement.stop_moving_towards(src) + + LAZYCLEARLIST(planned_behaviors) + LAZYCLEARLIST(planning_subtrees) + LAZYCLEARLIST(current_behaviors) + + return ..() + +/// Sets the current movement target, with an optional param to override the movement behavior +/datum/ai_controller/proc/set_movement_target(source, atom/target, datum/ai_movement/new_movement) + movement_target_source = source + current_movement_target = target + if(new_movement) + change_ai_movement_type(new_movement) + +/// Overrides the current ai_movement of this controller with a new one +/datum/ai_controller/proc/change_ai_movement_type(datum/ai_movement/new_movement) + ai_movement = SSai_movement.movement_types[new_movement] + +/// Completely replaces the planning_subtrees with a new set based on argument provided. +/// List provided must contain specifically typepaths +/datum/ai_controller/proc/replace_planning_subtrees(list/typepaths_of_new_subtrees) + planning_subtrees = typepaths_of_new_subtrees + init_subtrees() + +/// Loops over the subtrees in planning_subtrees and looks at the ai_controllers to grab a reference +/// Ensure planning_subtrees are typepaths and not instances/references before executing this! +/datum/ai_controller/proc/init_subtrees() + if(!LAZYLEN(planning_subtrees)) + return + var/list/temp_subtree_list = list() + for(var/subtree in planning_subtrees) + var/subtree_instance = SSai_controllers.ai_subtrees[subtree] + temp_subtree_list += subtree_instance + planning_subtrees = temp_subtree_list + +/// Proc to move from one pawn to another. This will destroy the target's existing controller. +/datum/ai_controller/proc/possess_pawn(atom/new_pawn) + SHOULD_CALL_PARENT(TRUE) + + if(!istype(new_pawn)) + qdel(src) + CRASH("[src] attempted to attach to null pawn!") + + if(pawn) // Reset any old signals + unpossess_pawn(FALSE) + + if(istype(new_pawn.ai_controller)) // Existing AI, kill it. + QDEL_NULL(new_pawn.ai_controller) + + if(try_possess_pawn(new_pawn) & AI_CONTROLLER_INCOMPATIBLE) + qdel(src) + CRASH("[src] attached to [new_pawn] but these are not compatible!") + + pawn = new_pawn + pawn.ai_controller = src + + var/turf/pawn_turf = get_turf(pawn) + if(pawn_turf) + SSai_controllers.ai_controllers_by_zlevel[pawn_turf.z] += src + + SEND_SIGNAL(src, COMSIG_AI_CONTROLLER_POSSESSED_PAWN) + + reset_ai_status() + RegisterSignal(pawn, COMSIG_MOVABLE_Z_CHANGED, PROC_REF(on_changed_z_level)) + RegisterSignal(pawn, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_changed)) + RegisterSignal(pawn, COMSIG_MOB_LOGIN, PROC_REF(on_sentience_gained)) + RegisterSignal(pawn, COMSIG_PARENT_QDELETING, PROC_REF(on_pawn_qdeleted)) + +/// Sets the AI on or off based on current conditions, call to reset after you've manually disabled it somewhere +/datum/ai_controller/proc/reset_ai_status() + set_ai_status(get_expected_ai_status()) + +/** + * Gets the AI status we expect the AI controller to be on at this current + * moment. Returns` AI_STATUS_OFF` if it's inhabited by a client and shouldn't be, + * if it's dead and cannot act while dead, or is on a z level without clients. + * Returns AI_STATUS_ON otherwise. + */ +/datum/ai_controller/proc/get_expected_ai_status() + . = AI_STATUS_ON + + if(!ismob(pawn)) + return + + var/mob/living/mob_pawn = pawn + if(!continue_processing_when_client && mob_pawn.client) + . = AI_STATUS_OFF + + if(ai_traits & AI_FLAG_CAN_ACT_WHILE_DEAD) + return + + if(mob_pawn.stat == DEAD) + . = AI_STATUS_OFF + + var/turf/pawn_turf = get_turf(mob_pawn) +#ifdef GAME_TESTS + if(!pawn_turf) + CRASH("AI controller [src] controlling pawn ([pawn]) is not on a turf.") +#endif + if(!length(SSmobs.clients_by_zlevel[pawn_turf.z])) + . = AI_STATUS_OFF + +/// Called when the AI controller pawn changes z levels. +/// We check if there's any clients on the new one and wake up the AI if there is. +/datum/ai_controller/proc/on_changed_z_level(atom/source, turf/old_turf, turf/new_turf, same_z_layer, notify_contents) + SIGNAL_HANDLER // COMSIG_MOVABLE_Z_CHANGED + + if(ismob(pawn)) + var/mob/mob_pawn = pawn + if(mob_pawn?.client && !continue_processing_when_client) + return + if(old_turf) + SSai_controllers.ai_controllers_by_zlevel[old_turf.z] -= src + if(new_turf) + SSai_controllers.ai_controllers_by_zlevel[new_turf.z] += src + var/new_level_clients = length(SSmobs.clients_by_zlevel[new_turf.z]) + if(new_level_clients) + set_ai_status(AI_STATUS_IDLE) + else + set_ai_status(AI_STATUS_OFF) + +/// Abstract proc for initializing the pawn to the new controller +/datum/ai_controller/proc/try_possess_pawn(atom/new_pawn) + return + +/// Proc for deinitializing the pawn to the old controller +/datum/ai_controller/proc/unpossess_pawn(destroy) + if(isnull(pawn)) + return // instantiated without an applicable pawn, fine + + UnregisterSignal(pawn, list(COMSIG_MOB_LOGIN, COMSIG_MOB_LOGOUT, COMSIG_MOB_STATCHANGE, COMSIG_PARENT_QDELETING)) + if(ai_movement.moving_controllers[src]) + ai_movement.stop_moving_towards(src) + var/turf/pawn_turf = get_turf(pawn) + if(pawn_turf) + SSai_controllers.ai_controllers_by_zlevel[pawn_turf.z] -= src + if(ai_status) + SSai_controllers.ai_controllers_by_status[ai_status] -= src + pawn.ai_controller = null + pawn = null + if(destroy) + qdel(src) + +/// Returns TRUE if the ai controller can actually run at the moment. +/datum/ai_controller/proc/able_to_run() + if(HAS_TRAIT(pawn, TRAIT_AI_PAUSED)) + return FALSE + if(world.time < paused_until) + return FALSE + return TRUE + + +/// Runs any actions that are currently running +/datum/ai_controller/process(seconds_per_tick) + + if(!able_to_run()) + GLOB.move_manager.stop_looping(pawn) //stop moving + return //this should remove them from processing in the future through event-based stuff. + + if(!LAZYLEN(current_behaviors) && idle_behavior) + idle_behavior.perform_idle_behavior(seconds_per_tick, src) //Do some stupid shit while we have nothing to do + return + + if(current_movement_target) + if(!isatom(current_movement_target)) + stack_trace("[pawn]'s current movement target is [current_movement_target], not an atom!") + cancel_actions() + return + + if(get_dist(pawn, current_movement_target) > max_target_distance) //The distance is out of range + cancel_actions() + return + + for(var/datum/ai_behavior/current_behavior as anything in current_behaviors) + // Convert the current behaviour action cooldown to realtime seconds from deciseconds.current_behavior + // Then pick the max of this and the seconds_per_tick passed to ai_controller.process() + // Action cooldowns cannot happen faster than seconds_per_tick, so seconds_per_tick should be the value used in this scenario. + var/action_seconds_per_tick = max(current_behavior.get_cooldown(src) * 0.1, seconds_per_tick) + + if(current_behavior.behavior_flags & AI_BEHAVIOR_REQUIRE_MOVEMENT) //Might need to move closer + if(!current_movement_target) + stack_trace("[pawn] wants to perform action type [current_behavior.type] which requires movement, but has no current movement target!") + return // This can cause issues, so don't let these slide. + + // Stops pawns from performing such actions that should require the target to be adjacent. + var/atom/movable/moving_pawn = pawn + var/can_reach = !(current_behavior.behavior_flags & AI_BEHAVIOR_REQUIRE_REACH) || moving_pawn.can_reach_nested_adjacent(current_movement_target) + if(can_reach && current_behavior.required_distance >= get_dist(moving_pawn, current_movement_target)) // Are we close enough to engage? + if(ai_movement.moving_controllers[src] == current_movement_target) // We are close enough, if we're moving stop. + ai_movement.stop_moving_towards(src) + + if(behavior_cooldowns[current_behavior] > world.time) // Still on cooldown + continue + process_behavior(action_seconds_per_tick, current_behavior) + return + + else if(ai_movement.moving_controllers[src] != current_movement_target) // We're too far, if we're not already moving start doing it. + ai_movement.start_moving_towards(src, current_movement_target, current_behavior.required_distance) // Then start moving + + if(current_behavior.behavior_flags & AI_BEHAVIOR_MOVE_AND_PERFORM) // If we can move and perform then do so. + if(behavior_cooldowns[current_behavior] > world.time) // Still on cooldown + continue + process_behavior(action_seconds_per_tick, current_behavior) + return + else // No movement required + if(behavior_cooldowns[current_behavior] > world.time) // Still on cooldown + continue + process_behavior(action_seconds_per_tick, current_behavior) + return + +/// Determines whether the AI can currently make a new plan. +/datum/ai_controller/proc/able_to_plan() + . = TRUE + if(QDELETED(pawn)) + return FALSE + for(var/datum/ai_behavior/current_behavior as anything in current_behaviors) + if(!(current_behavior.behavior_flags & AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION)) // We have a behavior that blocks planning + return FALSE + +/// This is where you decide what actions are taken by the AI. +/datum/ai_controller/proc/select_behaviors(seconds_per_tick) + SHOULD_NOT_SLEEP(TRUE) + + if(!COOLDOWN_FINISHED(src, failed_planning_cooldown)) + return FALSE + + LAZYINITLIST(current_behaviors) + LAZYCLEARLIST(planned_behaviors) + + if(LAZYLEN(planning_subtrees)) + for(var/datum/ai_planning_subtree/subtree as anything in planning_subtrees) + if(subtree.select_behaviors(src, seconds_per_tick) == SUBTREE_RETURN_FINISH_PLANNING) + break + + for(var/datum/ai_behavior/current_behavior as anything in current_behaviors) + if(LAZYACCESS(planned_behaviors, current_behavior)) + continue + var/list/arguments = list(src, FALSE) + var/list/stored_arguments = behavior_args[type] + if(stored_arguments) + arguments += stored_arguments + current_behavior.finish_action(arglist(arguments)) + +/// This proc handles changing AI status, and starts/stops processing if required. +/datum/ai_controller/proc/set_ai_status(new_ai_status) + if(ai_status == new_ai_status) + return FALSE // no change + + // remove old status, if we've got one + if(ai_status) + SSai_controllers.ai_controllers_by_status[ai_status] -= src + ai_status = new_ai_status + SSai_controllers.ai_controllers_by_status[new_ai_status] += src + switch(ai_status) + if(AI_STATUS_ON) + START_PROCESSING(SSai_behaviors, src) + if(AI_STATUS_OFF, AI_STATUS_IDLE) + STOP_PROCESSING(SSai_behaviors, src) + cancel_actions() + +/datum/ai_controller/proc/pause_ai(time) + paused_until = world.time + time + +/datum/ai_controller/proc/modify_cooldown(datum/ai_behavior/behavior, new_cooldown) + behavior_cooldowns[behavior] = new_cooldown + +/// Call this to add a behavior to the stack. +/datum/ai_controller/proc/queue_behavior(behavior_type, ...) + var/datum/ai_behavior/behavior = GET_AI_BEHAVIOR(behavior_type) + if(!behavior) + CRASH("Behavior [behavior_type] not found.") + var/list/arguments = args.Copy() + arguments[1] = src + + // It's still in the plan, don't add it again to current_behaviors + // but do keep it in the planned behavior list so its not cancelled + if(LAZYACCESS(current_behaviors, behavior)) + LAZYADDASSOC(planned_behaviors, behavior, TRUE) + return + + if(!behavior.setup(arglist(arguments))) + return + LAZYADDASSOC(current_behaviors, behavior, TRUE) + LAZYADDASSOC(planned_behaviors, behavior, TRUE) + arguments.Cut(1, 2) + if(length(arguments)) + behavior_args[behavior_type] = arguments + else + behavior_args -= behavior_type + SEND_SIGNAL(src, AI_CONTROLLER_BEHAVIOR_QUEUED(behavior_type), arguments) + +/datum/ai_controller/proc/process_behavior(seconds_per_tick, datum/ai_behavior/behavior) + var/list/arguments = list(seconds_per_tick, src) + var/list/stored_arguments = behavior_args[behavior.type] + if(stored_arguments) + arguments += stored_arguments + + var/process_flags = behavior.perform(arglist(arguments)) + if(process_flags & AI_BEHAVIOR_DELAY) + behavior_cooldowns[behavior] = world.time + behavior.get_cooldown(src) + if(process_flags & AI_BEHAVIOR_FAILED) + arguments[1] = src + arguments[2] = FALSE + behavior.finish_action(arglist(arguments)) + else if(process_flags & AI_BEHAVIOR_SUCCEEDED) + arguments[1] = src + arguments[2] = TRUE + behavior.finish_action(arglist(arguments)) + +/datum/ai_controller/proc/cancel_actions() + if(!LAZYLEN(current_behaviors)) + return + for(var/datum/ai_behavior/current_behavior as anything in current_behaviors) + var/list/arguments = list(src, FALSE) + var/list/stored_arguments = behavior_args[current_behavior.type] + if(stored_arguments) + arguments += stored_arguments + current_behavior.finish_action(arglist(arguments)) + +/// Turn the controller on or off based on if you're alive. +/// We only register to this if the flag is present so don't need to check again. +/datum/ai_controller/proc/on_stat_changed(mob/living/source, new_stat) + SIGNAL_HANDLER // COMSIG_MOB_STATCHANGE + reset_ai_status() + +/datum/ai_controller/proc/on_sentience_gained() + SIGNAL_HANDLER // COMSIG_MOB_LOGIN + UnregisterSignal(pawn, COMSIG_MOB_LOGIN) + if(!continue_processing_when_client) + set_ai_status(AI_STATUS_OFF) // Can't do anything while player is connected + RegisterSignal(pawn, COMSIG_MOB_LOGOUT, PROC_REF(on_sentience_lost)) + +/datum/ai_controller/proc/on_sentience_lost() + SIGNAL_HANDLER // COMSIG_MOB_LOGOUT + UnregisterSignal(pawn, COMSIG_MOB_LOGOUT) + set_ai_status(AI_STATUS_IDLE) + RegisterSignal(pawn, COMSIG_MOB_LOGIN, PROC_REF(on_sentience_gained)) + +/// Turn the controller off if the pawn has been qdeleted. +/datum/ai_controller/proc/on_pawn_qdeleted() + SIGNAL_HANDLER // COMSIG_PARENT_QDELETING + set_ai_status(AI_STATUS_OFF) + set_movement_target(type, null) + if(ai_movement.moving_controllers[src]) + ai_movement.stop_moving_towards(src) + +/// Use this proc to define how your controller defines what access the pawn has for the sake of pathfinding. +/// Return the access list you want to use. +/datum/ai_controller/proc/get_access() + return + +/// Returns the minimum required distance to preform one of our current behaviors. +/// Honestly this should just be cached or something but fuck you +/datum/ai_controller/proc/get_minimum_distance() + var/minimum_distance = max_target_distance + // right now I'm just taking the shortest minimum distance of our current behaviors, at some point in the future + // we should let whatever sets the current_movement_target also set the min distance and max path length + // (or at least cache it on the controller) + for(var/datum/ai_behavior/iter_behavior as anything in current_behaviors) + if(iter_behavior.required_distance < minimum_distance) + minimum_distance = iter_behavior.required_distance + return minimum_distance + +/// Returns true if we have a blackboard key with the provided key and it is not qdeleting. +/datum/ai_controller/proc/blackboard_key_exists(key) + var/datum/key_value = blackboard[key] + if(isdatum(key_value)) + return !QDELETED(key_value) + if(islist(key_value)) + return length(key_value) > 0 + return !!key_value + +/** + * Used to manage references to datum by AI controllers + * + * * tracked_datum - something being added to an ai blackboard + * * key - the associated key + */ +#define TRACK_AI_DATUM_TARGET(tracked_datum, key) do { \ + if(isdatum(tracked_datum)) { \ + var/datum/_tracked_datum = tracked_datum; \ + if(!HAS_TRAIT_FROM(_tracked_datum, TRAIT_AI_TRACKING, "[UID(src)]_[key]")) { \ + RegisterSignal(_tracked_datum, COMSIG_PARENT_QDELETING, PROC_REF(sig_remove_from_blackboard), override = TRUE); \ + ADD_TRAIT(_tracked_datum, TRAIT_AI_TRACKING, "[UID(src)]_[key]"); \ + }; \ + }; \ +} while(FALSE) + +/** + * Used to clear previously set reference handing by AI controllers + * + * * tracked_datum - something being removed from an ai blackboard + * * key - the associated key + */ +#define CLEAR_AI_DATUM_TARGET(tracked_datum, key) do { \ + if(isdatum(tracked_datum)) { \ + var/datum/_tracked_datum = tracked_datum; \ + REMOVE_TRAIT(_tracked_datum, TRAIT_AI_TRACKING, "[UID(src)]_[key]"); \ + if(!HAS_TRAIT(_tracked_datum, TRAIT_AI_TRACKING)) { \ + UnregisterSignal(_tracked_datum, COMSIG_PARENT_QDELETING); \ + }; \ + }; \ +} while(FALSE) + +/// Used for above to track all the keys that have registered a signal +#define TRAIT_AI_TRACKING "tracked_by_ai" + +/** + * Sets the key to the passed "thing". + * + * * key - A blackboard key + * * thing - a value to set the blackboard key to. + */ +/datum/ai_controller/proc/set_blackboard_key(key, thing) + // Assume it is an error when trying to set a value overtop a list + if(islist(blackboard[key])) + CRASH("set_blackboard_key attempting to set a blackboard value to key [key] when it's a list!") + // Don't do anything if it's already got this value + if(blackboard[key] == thing) + return + + // Clear existing values + if(!isnull(blackboard[key])) + clear_blackboard_key(key) + + TRACK_AI_DATUM_TARGET(thing, key) + blackboard[key] = thing + post_blackboard_key_set(key) + +/** + * Helper to force a key to be a certain thing no matter what's already there + * + * Useful for if you're overriding a list with a new list entirely, + * as otherwise it would throw a runtime error from trying to override a list + * + * Not necessary to use if you aren't dealing with lists, as set_blackboard_key will clear the existing value + * in that case already, but may be useful for clarity. + * + * * key - A blackboard key + * * thing - a value to set the blackboard key to. + */ +/datum/ai_controller/proc/override_blackboard_key(key, thing) + if(blackboard[key] == thing) + return + + clear_blackboard_key(key) + set_blackboard_key(key, thing) + +/** + * Sets the key at index thing to the passed value + * + * Assumes the key value is already a list, if not throws an error. + * + * * key - A blackboard key, with its value set to a list + * * thing - a value which becomes the inner list value's key + * * value - what to set the inner list's value to + */ +/datum/ai_controller/proc/set_blackboard_key_assoc(key, thing, value) + if(!islist(blackboard[key])) + CRASH("set_blackboard_key_assoc called on non-list key [key]!") + // Don't do anything if it's already got this value + if(blackboard[key][thing] == value) + return + + TRACK_AI_DATUM_TARGET(thing, key) + TRACK_AI_DATUM_TARGET(value, key) + blackboard[key][thing] = value + post_blackboard_key_set(key) + +/** + * Similar to [proc/set_blackboard_key_assoc] but operates under the assumption the key is a lazylist (so it will create a list) + * More dangerous / easier to override values, only use when you want to use a lazylist + * + * * key - A blackboard key, with its value set to a list + * * thing - a value which becomes the inner list value's key + * * value - what to set the inner list's value to + */ +/datum/ai_controller/proc/set_blackboard_key_assoc_lazylist(key, thing, value) + LAZYINITLIST(blackboard[key]) + // Don't do anything if it's already got this value + if(blackboard[key][thing] == value) + return + + TRACK_AI_DATUM_TARGET(thing, key) + TRACK_AI_DATUM_TARGET(value, key) + blackboard[key][thing] = value + post_blackboard_key_set(key) + +/** + * Called after we set a blackboard key, forwards signal information. + */ +/datum/ai_controller/proc/post_blackboard_key_set(key) + if(isnull(pawn)) + return + SEND_SIGNAL(pawn, COMSIG_AI_BLACKBOARD_KEY_SET(key), key) + +/** + * Adds the passed "thing" to the associated key + * + * Works with lists or numbers, but not lazylists. + * + * * key - A blackboard key + * * thing - a value to set the blackboard key to. + */ +/datum/ai_controller/proc/add_blackboard_key(key, thing) + TRACK_AI_DATUM_TARGET(thing, key) + blackboard[key] += thing + +/** + * Similar to [proc/add_blackboard_key], but performs an insertion rather than an add + * Throws an error if the key is not a list already, intended only for use with lists + * + * * key - A blackboard key, with its value set to a list + * * thing - a value to set the blackboard key to. + */ +/datum/ai_controller/proc/insert_blackboard_key(key, thing) + if(!islist(blackboard[key])) + CRASH("insert_blackboard_key called on non-list key [key]!") + TRACK_AI_DATUM_TARGET(thing, key) + blackboard[key] |= thing + +/** + * Adds the passed "thing" to the associated key, assuming key is intended to be a lazylist (so it will create a list) + * More dangerous / easier to override values, only use when you want to use a lazylist + * + * * key - A blackboard key + * * thing - a value to set the blackboard key to. + */ +/datum/ai_controller/proc/add_blackboard_key_lazylist(key, thing) + LAZYINITLIST(blackboard[key]) + TRACK_AI_DATUM_TARGET(thing, key) + blackboard[key] += thing + +/** + * Similar to [proc/insert_blackboard_key_lazylist], but performs an insertion / or rather than an add + * + * * key - A blackboard key + * * thing - a value to set the blackboard key to. + */ +/datum/ai_controller/proc/insert_blackboard_key_lazylist(key, thing) + LAZYINITLIST(blackboard[key]) + TRACK_AI_DATUM_TARGET(thing, key) + blackboard[key] |= thing + +/** + * Adds the value to the inner list at key with the inner key set to "thing" + * Throws an error if the key is not a list already, intended only for use with lists + * + * * key - A blackboard key, with its value set to a list + * * thing - a value which becomes the inner list value's key + * * value - what to set the inner list's value to + */ +/datum/ai_controller/proc/add_blackboard_key_assoc(key, thing, value) + if(!islist(blackboard[key])) + CRASH("add_blackboard_key_assoc called on non-list key [key]!") + TRACK_AI_DATUM_TARGET(thing, key) + TRACK_AI_DATUM_TARGET(value, key) + blackboard[key][thing] += value + +/** + * Similar to [proc/add_blackboard_key_assoc], assuming key is intended to be a lazylist (so it will create a list) + * More dangerous / easier to override values, only use when you want to use a lazylist + * + * * key - A blackboard key, with its value set to a list + * * thing - a value which becomes the inner list value's key + * * value - what to set the inner list's value to + */ +/datum/ai_controller/proc/add_blackboard_key_assoc_lazylist(key, thing, value) + LAZYINITLIST(blackboard[key]) + TRACK_AI_DATUM_TARGET(thing, key) + TRACK_AI_DATUM_TARGET(value, key) + blackboard[key][thing] += value + +/** + * Clears the passed key, resetting it to null + * + * Not intended for use with list keys - use [proc/remove_thing_from_blackboard_key] if you are removing a value from a list at a key + * + * * key - A blackboard key + */ +/datum/ai_controller/proc/clear_blackboard_key(key) + if(isnull(blackboard[key])) + return + if(pawn && (SEND_SIGNAL(pawn, COMSIG_AI_BLACKBOARD_KEY_PRECLEAR(key)))) + return + CLEAR_AI_DATUM_TARGET(blackboard[key], key) + blackboard[key] = null + if(isnull(pawn)) + return + SEND_SIGNAL(pawn, COMSIG_AI_BLACKBOARD_KEY_CLEARED(key)) + +/** + * Remove the passed thing from the associated blackboard key + * + * Intended for use with lists, if you're just clearing a reference from a key use [proc/clear_blackboard_key] + * + * * key - A blackboard key + * * thing - a value to set the blackboard key to. + */ +/datum/ai_controller/proc/remove_thing_from_blackboard_key(key, thing) + var/associated_value = blackboard[key] + if(thing == associated_value) + stack_trace("remove_thing_from_blackboard_key was called un-necessarily in a situation where clear_blackboard_key would suffice. ") + clear_blackboard_key(key) + return + + if(!islist(associated_value)) + CRASH("remove_thing_from_blackboard_key called with an invalid \"thing\" argument ([thing]). \ + (The associated value of the passed key is not a list and is also not the passed thing, meaning it is clearing an unintended value.)") + + for(var/inner_key in associated_value) + if(inner_key == thing) + // flat list + CLEAR_AI_DATUM_TARGET(thing, key) + associated_value -= thing + return + else if(associated_value[inner_key] == thing) + // assoc list + CLEAR_AI_DATUM_TARGET(thing, key) + associated_value -= inner_key + return + + CRASH("remove_thing_from_blackboard_key called with an invalid \"thing\" argument ([thing]). \ + (The passed value is not tracked in the passed list.)") + +/// Removes a tracked object from a lazylist. +/datum/ai_controller/proc/remove_from_blackboard_lazylist_key(key, thing) + var/lazylist = blackboard[key] + if(isnull(lazylist)) + return + for(var/key_index in lazylist) + if(thing == key_index || lazylist[key_index] == thing) + CLEAR_AI_DATUM_TARGET(thing, key) + lazylist -= key_index + break + if(!LAZYLEN(lazylist)) + clear_blackboard_key(key) + +/// Signal proc to go through every key and remove the datum from all keys it finds. +/datum/ai_controller/proc/sig_remove_from_blackboard(datum/source) + SIGNAL_HANDLER // COMSIG_PARENT_QDELETING + + var/list/list/remove_queue = list(blackboard) + var/index = 1 + while(index <= length(remove_queue)) + var/list/next_to_clear = remove_queue[index] + for(var/inner_value in next_to_clear) + var/associated_value = next_to_clear[inner_value] + // We are a lists of lists, add the next value to the queue so we can handle references in there + // (But we only need to bother checking the list if it's not empty.) + if(islist(inner_value) && length(inner_value)) + UNTYPED_LIST_ADD(remove_queue, inner_value) + + // We found the value that's been deleted. Clear it out from this list + else if(inner_value == source) + next_to_clear -= inner_value + + // We are an assoc lists of lists, the list at the next value so we can handle references in there + // (But again, we only need to bother checking the list if it's not empty.) + if(islist(associated_value) && length(associated_value)) + UNTYPED_LIST_ADD(remove_queue, associated_value) + + // We found the value that's been deleted, it was an assoc value. Clear it out entirely + else if(associated_value == source) + next_to_clear -= inner_value + SEND_SIGNAL(pawn, COMSIG_AI_BLACKBOARD_KEY_CLEARED(inner_value)) + + index += 1 + +#undef TRACK_AI_DATUM_TARGET +#undef CLEAR_AI_DATUM_TARGET +#undef TRAIT_AI_TRACKING diff --git a/code/datums/ai/ai_planning_subtree.dm b/code/datums/ai/ai_planning_subtree.dm new file mode 100644 index 000000000000..4ab224cd8d71 --- /dev/null +++ b/code/datums/ai/ai_planning_subtree.dm @@ -0,0 +1,11 @@ +/// A subtree is attached to a controller and is occasionally called by +/// /ai_controller/select_behaviors(). This mainly exists to act as a way to +/// subtype and modify select_behaviors() without needing to subtype the AI +/// controller itself. +/datum/ai_planning_subtree + +/// Determines what behaviors should the controller try processing; if this +/// returns SUBTREE_RETURN_FINISH_PLANNING then the controller won't go through +/// the other subtrees should multiple exist in the controller. +/datum/ai_planning_subtree/proc/select_behaviors(datum/ai_controller/controller, seconds_per_tick) + return diff --git a/code/datums/ai/idle_behaviors/idle_behavior.dm b/code/datums/ai/idle_behaviors/idle_behavior.dm new file mode 100644 index 000000000000..315233bb71d5 --- /dev/null +++ b/code/datums/ai/idle_behaviors/idle_behavior.dm @@ -0,0 +1,4 @@ +/datum/idle_behavior + +/datum/idle_behavior/proc/perform_idle_behavior(seconds_per_tick, datum/ai_controller/controller) + return diff --git a/code/datums/ai/movement/ai_movement.dm b/code/datums/ai/movement/ai_movement.dm new file mode 100644 index 000000000000..98f302111abb --- /dev/null +++ b/code/datums/ai/movement/ai_movement.dm @@ -0,0 +1,75 @@ +/// This datum is an abstract class that can be overriden for different types of movement. +/datum/ai_movement + /// Assoc list ist of controllers that are currently moving as key, and what they are moving to as value. + var/list/moving_controllers = list() + /// How many times a given controller can fail on their route before they just give up. + var/max_pathing_attempts + +/// Override this to setup the moveloop you want to use. +/datum/ai_movement/proc/start_moving_towards(datum/ai_controller/controller, atom/current_movement_target, min_distance) + SHOULD_CALL_PARENT(TRUE) + controller.consecutive_pathing_attempts = 0 + controller.set_blackboard_key(BB_CURRENT_MIN_MOVE_DISTANCE, min_distance) + moving_controllers[controller] = current_movement_target + +/datum/ai_movement/proc/stop_moving_towards(datum/ai_controller/controller) + controller.consecutive_pathing_attempts = 0 + moving_controllers -= controller + // We got deleted as we finished an action + if(!QDELETED(controller.pawn)) + GLOB.move_manager.stop_looping(controller.pawn, SSai_movement) + +/datum/ai_movement/proc/increment_pathing_failures(datum/ai_controller/controller) + controller.consecutive_pathing_attempts++ + if(controller.consecutive_pathing_attempts >= max_pathing_attempts) + controller.cancel_actions() + +/// Returns TRUE if the movement should be allowed, FALSE otherwise. +/datum/ai_movement/proc/allowed_to_move(datum/move_loop/source) + SHOULD_BE_PURE(TRUE) + + var/atom/movable/pawn = source.moving + var/datum/ai_controller/controller = source.extra_info + + var/can_move = TRUE + if((controller.ai_traits & AI_FLAG_STOP_MOVING_WHEN_PULLED) && pawn.pulledby) + can_move = FALSE + + if(!isturf(pawn.loc)) // No moving if not on a turf + can_move = FALSE + + if(isliving(pawn)) + var/mob/living/pawn_mob = pawn + if(!(pawn_mob.mobility_flags & MOBILITY_MOVE)) + can_move = FALSE + + return can_move + +/// Anything to do before moving; any checks if the pawn should be able to +/// move should be placed in allowed_to_move() and called by this proc. +/datum/ai_movement/proc/pre_move(datum/move_loop/source) + SIGNAL_HANDLER // COMSIG_MOVELOOP_PREPROCESS_CHECK + SHOULD_NOT_OVERRIDE(TRUE) + + var/datum/ai_controller/controller = source.extra_info + + // Check if this controller can actually run, so we don't chase people with corpses + if(!controller.able_to_run()) + controller.cancel_actions() + qdel(source) // stop moving + return MOVELOOP_SKIP_STEP + + source.delay = controller.movement_delay + + if(allowed_to_move(source)) + return NONE + increment_pathing_failures(controller) + return MOVELOOP_SKIP_STEP + +/// Anything to do post-movement. +/datum/ai_movement/proc/post_move(datum/move_loop/source, succeeded) + SIGNAL_HANDLER // COMSIG_MOVELOOP_POSTPROCESS + if(succeeded != FALSE) + return + var/datum/ai_controller/controller = source.extra_info + increment_pathing_failures(controller) diff --git a/code/datums/ai/targeting_strategy.dm b/code/datums/ai/targeting_strategy.dm new file mode 100644 index 000000000000..45e38374d765 --- /dev/null +++ b/code/datums/ai/targeting_strategy.dm @@ -0,0 +1,15 @@ +/// Datum for basic mobs to define what they can attack. +/// Global, just like ai_behaviors. +/// Meant to be subtyped into different kinds of targeting strategies. +/datum/targeting_strategy + +/// Returns true or false depending on if the target can be attacked by the mob. +/datum/targeting_strategy/proc/can_attack(mob/living/living_mob, atom/target, vision_range) + return + +/// Returns something the target might be hiding inside of. +/datum/targeting_strategy/proc/find_hidden_mobs(mob/living/living_mob, atom/target) + var/atom/target_hiding_location + if(istype(target.loc, /obj/structure) || istype(target.loc, /obj/machinery)) + target_hiding_location = target.loc + return target_hiding_location diff --git a/code/game/atoms.dm b/code/game/atoms.dm index b11e4567c297..78409ed8dfaa 100644 --- a/code/game/atoms.dm +++ b/code/game/atoms.dm @@ -128,6 +128,8 @@ var/receive_ricochet_chance_mod = 1 ///When a projectile ricochets off this atom, it deals the normal damage * this modifier to this atom var/receive_ricochet_damage_coeff = 0.33 + /// AI controller that controls this atom. type on init, then turned into an instance during runtime + var/datum/ai_controller/ai_controller /// Whether this atom is using the new attack chain. var/new_attack_chain = FALSE @@ -189,6 +191,11 @@ smoothing_flags |= SMOOTH_OBJ SET_BITFLAG_LIST(canSmoothWith) + if(ispath(ai_controller, /datum/ai_controller)) + ai_controller = new ai_controller(src) + else if(!isnull(ai_controller)) + stack_trace("[src] expected an ai controller typepath or null for its AI controller, but was instead given [ai_controller].") + return INITIALIZE_HINT_NORMAL //called if Initialize returns INITIALIZE_HINT_LATELOAD @@ -1442,3 +1449,11 @@ GLOBAL_LIST_EMPTY(blood_splatter_icons) /// Used with the spawner component to do something when a mob is spawned. /atom/proc/on_mob_spawn(mob/created_mob) return + +///Returns the src and all recursive contents as a list. +/atom/proc/get_all_contents() + . = list(src) + var/i = 0 + while(i < length(.)) + var/atom/checked_atom = .[++i] + . += checked_atom.contents diff --git a/code/game/atoms_movable.dm b/code/game/atoms_movable.dm index 0dd727be6299..a23242705dba 100644 --- a/code/game/atoms_movable.dm +++ b/code/game/atoms_movable.dm @@ -1139,3 +1139,39 @@ /atom/movable/proc/scatter_atom(x_offset = 0, y_offset = 0) pixel_x = x_offset + rand(-scatter_distance, scatter_distance) pixel_y = y_offset + rand(-scatter_distance, scatter_distance) + +/** + * A backwards depth-limited breadth-first-search to see if the target is + * logically "in" anything adjacent to us. + * + * Arguments: + * * ultimate_target - the specific item we're attempting to reach. + * * tool - if present, checked to see if the tool can reach the target via [/obj/item/var/reach]. + * * view_only - if TRUE, only considers locations in atoms visible to us, as opposed to nested inventories. + */ +/atom/movable/proc/can_reach_nested_adjacent(atom/ultimate_target, obj/item/tool, view_only = FALSE) + var/list/direct_access = direct_access() + var/depth = 1 + (view_only ? STORAGE_VIEW_DEPTH : INVENTORY_DEPTH) + + var/list/closed = list() + var/list/checking = list(ultimate_target) + + while(length(checking) && depth > 0) + var/list/next = list() + --depth + + for(var/atom/target in checking) // will filter out nulls + if(closed[target] || isarea(target)) // avoid infinity situations + continue + + if(isturf(target) || isturf(target.loc) || (target in direct_access)) //Directly accessible atoms + if(Adjacent(target) || (tool && check_tool_reach(src, target, tool.reach))) //Adjacent or reaching attacks + return TRUE + + closed[target] = TRUE + + if(!target.loc) + continue + + checking = next + return FALSE diff --git a/code/modules/mob/mob_login_base.dm b/code/modules/mob/mob_login_base.dm index 7c2a6354fa9b..d0676420680a 100644 --- a/code/modules/mob/mob_login_base.dm +++ b/code/modules/mob/mob_login_base.dm @@ -60,6 +60,8 @@ // For us, (1,1,1) is a space tile. This means roughly 200,000! calls to Move() // You do not want this + SEND_SIGNAL(src, COMSIG_MOB_LOGIN) + reset_perspective(loc) @@ -82,3 +84,4 @@ update_morgue() client.init_verbs() SEND_SIGNAL(src, COMSIG_MOB_LOGIN) + SEND_SIGNAL(src, COMSIG_MOB_CLIENT_LOGIN, client) diff --git a/code/modules/mob/mob_logout_base.dm b/code/modules/mob/mob_logout_base.dm index 59a13aacbeb7..3b178e91b808 100644 --- a/code/modules/mob/mob_logout_base.dm +++ b/code/modules/mob/mob_logout_base.dm @@ -1,4 +1,5 @@ /mob/Logout() + SEND_SIGNAL(src, COMSIG_MOB_LOGOUT) set_typing_indicator(FALSE) SStgui.on_logout(src) // Cleanup any TGUIs the user has open unset_machine() diff --git a/code/modules/space_management/zlevel_manager.dm b/code/modules/space_management/zlevel_manager.dm index 4887257493c9..4f0e58e44a0b 100644 --- a/code/modules/space_management/zlevel_manager.dm +++ b/code/modules/space_management/zlevel_manager.dm @@ -113,6 +113,7 @@ GLOBAL_DATUM_INIT(space_manager, /datum/zlev_manager, new()) if(name in levels_by_name) throw EXCEPTION("Name already in use: [name]") world.maxz++ + SSai_controllers.on_max_z_changed() var/our_z = world.maxz milla_init_z(our_z) var/datum/space_level/S = new /datum/space_level(our_z, name, transition_type = linkage, traits = traits) diff --git a/docs/references/ai_controllers.md b/docs/references/ai_controllers.md new file mode 100644 index 000000000000..a03050b740e3 --- /dev/null +++ b/docs/references/ai_controllers.md @@ -0,0 +1,269 @@ +# AI Controllers + +!!! note + + This documentation is based off the README and tutorial for AI controllers + from TG. It has been edited and adapted for Paradise where necessary. + +## Introduction + +The AI controller system is an attempt at making it possible to create +modularized AI that stores its behavior in datums, while keeping state and +decision making in a controller. + +Prior to AI controllers, mob AI was built into the `/mob/living/simple_animal` +subtype, which were generally used as non-player controlled mobs. But by coding +AI directly into the mob, there was so little ability to make unique or +complicated AI, and even when it was pulled off the code was hacky and +non-reusable. The AI controller system was made to rectify these problems, and +expand AI beyond just mobs. + +## AI Controllers + +AI _controllers_ are datums that can be added to any atom in the game. Similarly +to components, they might only support a given subtype (e.g. `/mob/living`), but +the idea is that theoretically, you could apply a specific AI controller to as +big a group of different types as possible and it would still work. + +These datums handle both the normal movement of mobs, but also their decision +making, deciding which actions they will take based on the checks you put into +their `select_behaviors()` proc. + +If behaviors are selected, and the AI is in range, it will try to perform them. +It runs all the behaviors it currently has in parallel, allowing for it to, for +example, screech at someone while trying to attack them. As long as it has +behaviors running, it will not try to generate new plans, making it not waste +CPU when it already has an active goal. + +They also hold data for any of the actions they might need to use, such as +cooldowns, whether or not they're currently fighting, etc. This data is stored +in the _blackboard_. + +### Blackboard + +The blackboard is an associated list keyed with strings and with values of +whatever you want. These store information the mob has such as "am I attacking +someone?", "do I have a weapon?", or "what kind of food do I like?". By using an +associated list like this, no data needs to be stored on the actions themselves, +and you could make actions that work on multiple AI controllers if you so +pleased by making the key to use a variable. + +## AI Behavior + +AI _behaviors_ are the actions an AI can take. These can range from "do an +emote" to "attack this target until he is dead". They are singletons, and should +contain nothing but static data. Any dynamic data should be stored in the +blackboard, to allow different controllers to use the same behaviors. + +## Making Your AI + +Here we will show an example of some simple AI controller and behavior +implementations. + +### Attaching an AI Controller + +Any atom can have an AI controller. I'm choosing a basic mob for this guide, +because basic mobs stand as a nice "blank canvas" for AI on mobs. Simple animals +come with AI built into the mob; basic mobs don't, which is great for us adding +AI on top of it. + +Anyways, we just define the type of AI this mob has on the `ai_controller` var. +It starts as a type, but is turned into an instance once the mob is +instantiated. + +```dm +/mob/living/basic/cow + name = "cow" + desc = "Known for their milk, just don't tip them over." + // a lot more variables defining for us what a cow is + + ai_controller = /datum/ai_controller/basic/cow +``` + +### Controllers Themselves + +First, let's look at the blackboard. + +```dm +/datum/ai_controller/basic/cow + blackboard = list( + BB_TARGETING_STRATEGY = new /datum/targeting_strategy/basic/allow_items(), + BB_BASIC_MOB_TIP_REACTING = FALSE, + BB_BASIC_MOB_TIPPER = null, + ) +``` + +Think of the blackboard as the unique format for variables. They are set +initially, or by behaviors, **but never in subtrees.** Because we check +`blackboard[BB_SOME_KEY]` instead of a variable, we can wipe out variables and +slap new ones onto the AI as it runs. For example, this cow uses +`BB_BASIC_MOB_TIP_REACTING` and `BB_BASIC_MOB_TIPPER` because cows can get +tipped, and the AI needs to know that in the subtrees when it plans behavior. +And in fact, those two keys aren't required to be defined initially, it's just +for clarity that they are. + +Speaking of subtrees, let's look at that now. + +```dm +/datum/ai_controller/basic/cow + planning_subtrees = list( + // Goes first... + /datum/ai_planning_subtree/tip_reaction, + + // Goes second... + /datum/ai_planning_subtree/find_and_eat_food, + + // Goes last. But at any point, a previous subtree can end the chain. + // If a cow is tipped over, it shouldn't make random noises + // or try finding food! + /datum/ai_planning_subtree/random_speech/cow, + ) + // By the end, for however many subtrees ran, + // each one that did may have planned behavior for the AI to act on. +``` + +AIs work by planning specific behaviors, and subtrees are datums that bundle the +planning of behavior together. They run from top to bottom, and they can cancel +future subtrees. As an example, cows have their very first consideration be +tip_reaction, a subtree that prevents further subtrees like eating food and +random speech, as well as planning out how the cow reacts (looking sad at the +person who tipped it). + +```dm +/datum/ai_controller/basic/cow + ai_traits = null + ai_movement = /datum/ai_movement/basic_avoidance + idle_behavior = null +``` + +Finally, we have some more minor things. + +- `ai_traits` are flags for the AI, things like `AI_FLAG_STOP_MOVING_WHEN_PULLED`, + slightly modifying how the AI acts under some situations. +- `ai_movement` is how the mob moves to its movement target. This can range from + simple behaviors like `ai_movement/dumb` that always moves in the direction of + the target and hopes there's nothing in the way, all the way to + `ai_movement/jps`, that plans and occasionally recalcuates more complicated + paths, at the cost of more lag. +- `idle_behavior` is just some simpler behavior to perform when nothing has been + planned at all, like `idle_behavior/idle_random_walk` making a mob wander + passively. + +### Subtrees and Behaviors + +Okay, so we have blackboard variables, which are considered by subtrees to plan +behaviors. Let's actually look at a subtree planning behaviors, and behaviors +themselves. + +```dm +/// This subtree checks if the mob has a target. +/// If it doesn't, it plans looking for food. +/// If it does, it tries to eat the food via attacking it. +/datum/ai_planning_subtree/find_and_eat_food/select_behaviors( + datum/ai_controller/controller, seconds_per_tick) + // Get things out of blackboard + var/atom/target = locateUID(controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET]) + var/list/wanted = controller.blackboard[BB_BASIC_FOODS] + + // We see if we have a target + // (remember, anything can be in that blackboard, it's not a hard reference). + if(!target || QDELETED(target)) + // We need to find some food + controller.queue_behavior( + /datum/ai_behavior/find_and_set/in_list, + BB_BASIC_MOB_CURRENT_TARGET, + wanted + ) + // This allows further subtrees to plan, since we're doing + // a non-invasive behavior like checking the vicinity for food. + return + + // Now we know we have a target but should let a hostile + // subtree plan attacking humans. Let's check if it's actually food. + if(target in wanted) + controller.queue_behavior( + /datum/ai_behavior/basic_melee_attack, + BB_BASIC_MOB_CURRENT_TARGET, + BB_TARGETING_STRATEGY, + BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION + ) + // This prevents further subtrees from planning + // since we want to focus on eating the food. + return SUBTREE_RETURN_FINISH_PLANNING +``` + +And one of those behaviors, `basic_melee_attack`. As I have been doing so far, +I've dumped in a bunch of comments explaining how this one behavior gets mobs to +chase a target and slap it if in range. + +```dm +/// This behavior makes an AI get close to their movement target, +/// and attack every time perform() is called. +/datum/ai_behavior/basic_melee_attack + action_cooldown = 0.6 SECONDS + // Flag tells the AI it needs to have a movement target to work, + // and since it doesn't have "AI_BEHAVIOR_MOVE_AND_PERFORM", it + // won't call perform() every 0.6 seconds until it is in melee range. Smart! + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT + +/datum/ai_behavior/basic_melee_attack/setup( + datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + . = ..() + // All this is doing in setup is setting the movement target. + // Setup is called once when the behavior is first planned, + // and returning FALSE can cancel the behavior if something isn't right. + + // Hiding location is priority. + var/target_key = controller.blackboard[hiding_location_key] || controller.blackboard[target_key] + var/atom/target = locateUID(target_key) + if(!target) + return FALSE + // Now the AI_BEHAVIOR_REQUIRE_MOVEMENT flag will be happy; + // we have a target to always be moving towards. + controller.current_movement_target = target + +/// perform() will run every "action_cooldown" deciseconds as long as the +/// conditions are good for it to do so (we set "AI_BEHAVIOR_REQUIRE_MOVEMENT", +/// so it won't perform until in range). +/datum/ai_behavior/basic_melee_attack/perform( + seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + . = ..() + var/mob/living/basic/basic_mob = controller.pawn + // Targeting strategy will kill the action if not real anymore + var/atom/target = locateUID(controller.blackboard[target_key]) + var/datum/targeting_strategy/targeting_strategy = controller.blackboard[targeting_strategy_key] + + if(!targeting_strategy.can_attack(basic_mob, target)) + // We have a target that is no longer valid to attack. + // Remember that returning doesn't end the behavior, + // JUST this single performance. So we call "finish_action" + // with whether it succeeded in doing what it wanted to do + // (it didn't, so FALSE) and the blackboard keys passed + // into this behavior. + finish_action(controller, FALSE, target_key) + return // don't forget to end the performance too + + // If this is valid, they're hidden in something! + var/hiding_target = targeting_strategy.find_hidden_mobs(basic_mob, target) + + controller.set_blackboard_key(hiding_location_key, hiding_target) + + // And finally, we're in range, we have a valid target, we can attack. + // When they fall into crit, they will no longer be a valid target, + // so the melee behavior will end. + if(hiding_target) + basic_mob.melee_attack(hiding_target) + else + basic_mob.melee_attack(target) + +/// And so the action has ended. We can now clean up the AI's blackboard +/// based on the success of the action, and the keys passed in. +/datum/ai_behavior/basic_melee_attack/finish_action( + datum/ai_controller/controller, succeeded, target_key, targeting_strategy_key, hiding_location_key) + . = ..() + // If the behavior failed, the target is no longer valid, so we should + // lose aggro. We remove the target_key (which could be anything; it's + // whatever key was passed into the behavior by the subtree) from the blackboard. + if(!succeeded) + controller.clear_blackboard_key(target_key) +``` diff --git a/mkdocs.yml b/mkdocs.yml index aefc3c8ada41..ca3de05fc9ac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -100,3 +100,4 @@ nav: - 'Tick Order': './references/tick_order.md' - 'Movement Signals': './references/movement_signals.md' - 'Attack Chain': './references/attack_chain.md' + - 'AI Controllers': './references/ai_controllers.md' diff --git a/paradise.dme b/paradise.dme index a6e2dc64d220..ae0e12111fbf 100644 --- a/paradise.dme +++ b/paradise.dme @@ -149,6 +149,9 @@ #include "code\__DEFINES\wires_defines.dm" #include "code\__DEFINES\zlevel_defines.dm" #include "code\__DEFINES\zoom.dm" +#include "code\__DEFINES\ai\ai_defines.dm" +#include "code\__DEFINES\ai\blackboard_defines.dm" +#include "code\__DEFINES\dcs\ai_signals.dm" #include "code\__DEFINES\dcs\atom_signals.dm" #include "code\__DEFINES\dcs\attack_chain_signals.dm" #include "code\__DEFINES\dcs\basetype_signals.dm" @@ -303,6 +306,7 @@ #include "code\controllers\configuration\sections\vote_configuration.dm" #include "code\controllers\subsystem\SSacid.dm" #include "code\controllers\subsystem\SSafk.dm" +#include "code\controllers\subsystem\SSai_controllers.dm" #include "code\controllers\subsystem\SSair.dm" #include "code\controllers\subsystem\SSambience.dm" #include "code\controllers\subsystem\SSblackbox.dm" @@ -350,6 +354,7 @@ #include "code\controllers\subsystem\SSvote.dm" #include "code\controllers\subsystem\SSweather.dm" #include "code\controllers\subsystem\movement\movement_types.dm" +#include "code\controllers\subsystem\movement\SSai_movement.dm" #include "code\controllers\subsystem\movement\SSmovement.dm" #include "code\controllers\subsystem\movement\SSspacedrift.dm" #include "code\controllers\subsystem\non_firing\SSassets.dm" @@ -366,6 +371,7 @@ #include "code\controllers\subsystem\non_firing\SSserver_queue.dm" #include "code\controllers\subsystem\non_firing\SSsounds.dm" #include "code\controllers\subsystem\non_firing\SStitlescreen.dm" +#include "code\controllers\subsystem\processing\SSai_behaviors.dm" #include "code\controllers\subsystem\processing\SSdcs.dm" #include "code\controllers\subsystem\processing\SSfastprocess.dm" #include "code\controllers\subsystem\processing\SSfields.dm" @@ -428,6 +434,12 @@ #include "code\datums\tgs_event_handler.dm" #include "code\datums\verb_callbacks.dm" #include "code\datums\vision_override.dm" +#include "code\datums\ai\ai_behavior.dm" +#include "code\datums\ai\ai_controller.dm" +#include "code\datums\ai\ai_planning_subtree.dm" +#include "code\datums\ai\targeting_strategy.dm" +#include "code\datums\ai\idle_behaviors\idle_behavior.dm" +#include "code\datums\ai\movement\ai_movement.dm" #include "code\datums\cache\air_alarm.dm" #include "code\datums\cache\apc_cache.dm" #include "code\datums\cache\cache.dm"