diff --git a/code/__DEFINES/ai_pet_commands.dm b/code/__DEFINES/ai_pet_commands.dm
new file mode 100644
index 00000000000..8887a3875dd
--- /dev/null
+++ b/code/__DEFINES/ai_pet_commands.dm
@@ -0,0 +1,19 @@
+/// Blackboard field for the most recent command the pet was given
+#define BB_ACTIVE_PET_COMMAND "BB_active_pet_command"
+/// Follow your normal behaviour
+#define PET_COMMAND_NONE "pet_command_none"
+/// Don't take any actions at all
+#define PET_COMMAND_IDLE "pet_command_idle"
+/// Pursue and attack the pointed target
+#define PET_COMMAND_ATTACK "pet_command_attack"
+/// Pursue the person who made this command
+#define PET_COMMAND_FOLLOW "pet_commmand_follow"
+/// Use a targetted mob ability
+#define PET_COMMAND_USE_ABILITY "pet_command_use_ability"
+
+/// Blackboard field for what we actually want the pet to target
+#define BB_CURRENT_PET_TARGET "BB_current_pet_target"
+/// Blackboard field for how we target things, as usually we want to be more permissive than normal
+#define BB_PET_TARGETTING_DATUM "BB_pet_targetting"
+/// Typecache of weakrefs to mobs this mob is friends with, will follow their instructions and won't attack them
+#define BB_FRIENDS_LIST "BB_friends_list"
diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm
index 954d2a8a24e..282a474fdf6 100644
--- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm
+++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm
@@ -128,3 +128,6 @@
#define STOP_SACRIFICE (1<<0)
/// Don't send a message for sacrificing this thing, we have our own
#define SILENCE_SACRIFICE_MESSAGE (1<<1)
+
+/// From /mob/living/befriend() : (mob/living/new_friend)
+#define COMSIG_LIVING_BEFRIENDED "living_befriended"
diff --git a/code/datums/ai/basic_mobs/basic_ai_behaviors/basic_attacking.dm b/code/datums/ai/basic_mobs/basic_ai_behaviors/basic_attacking.dm
index 231193a277b..64600e03f54 100644
--- a/code/datums/ai/basic_mobs/basic_ai_behaviors/basic_attacking.dm
+++ b/code/datums/ai/basic_mobs/basic_ai_behaviors/basic_attacking.dm
@@ -9,6 +9,9 @@
var/atom/target = weak_target?.resolve()
if(!target)
return FALSE
+ var/datum/targetting_datum/targetting_datum = controller.blackboard[targetting_datum_key]
+ if (!targetting_datum)
+ return
controller.set_movement_target(target)
/datum/ai_behavior/basic_melee_attack/perform(delta_time, datum/ai_controller/controller, target_key, targetting_datum_key, hiding_location_key)
diff --git a/code/datums/ai/basic_mobs/pet_commands/pet_command_planning.dm b/code/datums/ai/basic_mobs/pet_commands/pet_command_planning.dm
new file mode 100644
index 00000000000..bf1b13b7853
--- /dev/null
+++ b/code/datums/ai/basic_mobs/pet_commands/pet_command_planning.dm
@@ -0,0 +1,18 @@
+/**
+ * # Pet Planning
+ * Perform behaviour based on what pet commands you have received. This is delegated to the pet command datum.
+ * When a command is set, we blackboard a key to our currently active command.
+ * The blackboard also has a weak reference to every command datum available to us.
+ * We use the key to figure out which datum to run, then ask it to figure out how to execute its action.
+ */
+/datum/ai_planning_subtree/pet_planning
+
+/datum/ai_planning_subtree/pet_planning/SelectBehaviors(datum/ai_controller/controller, delta_time)
+ var/active_command_key = controller.blackboard[BB_ACTIVE_PET_COMMAND]
+ if (!active_command_key)
+ return // Do something else
+ var/datum/weakref/weak_command = controller.blackboard[active_command_key]
+ var/datum/pet_command/command = weak_command?.resolve()
+ if (!command)
+ return // We forgot this command at some point
+ return command.execute_action(controller)
diff --git a/code/datums/ai/basic_mobs/pet_commands/pet_follow_friend.dm b/code/datums/ai/basic_mobs/pet_commands/pet_follow_friend.dm
new file mode 100644
index 00000000000..244a10332b0
--- /dev/null
+++ b/code/datums/ai/basic_mobs/pet_commands/pet_follow_friend.dm
@@ -0,0 +1,19 @@
+/// Just keep following the target until the command is interrupted
+/datum/ai_behavior/pet_follow_friend
+ behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM
+
+/datum/ai_behavior/pet_follow_friend/setup(datum/ai_controller/controller, target_key)
+ . = ..()
+ var/datum/weakref/weak_target = controller.blackboard[target_key]
+ var/atom/target = weak_target?.resolve()
+ if (!target)
+ return FALSE
+ controller.current_movement_target = target
+
+/datum/ai_behavior/pet_follow_friend/perform(delta_time, datum/ai_controller/controller, target_key)
+ . = ..()
+ var/datum/weakref/weak_target = controller.blackboard[target_key]
+ var/atom/target = weak_target?.resolve()
+ if (!target)
+ finish_action(controller, FALSE, target_key)
+ return
diff --git a/code/datums/ai/basic_mobs/pet_commands/pet_use_targetted_ability.dm b/code/datums/ai/basic_mobs/pet_commands/pet_use_targetted_ability.dm
new file mode 100644
index 00000000000..940993cb393
--- /dev/null
+++ b/code/datums/ai/basic_mobs/pet_commands/pet_use_targetted_ability.dm
@@ -0,0 +1,27 @@
+/// Pet owners can't see their pet's ability cooldowns so we keep attempting to use an ability until we succeed
+/datum/ai_behavior/pet_use_ability
+ behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM
+
+/datum/ai_behavior/pet_use_ability/setup(datum/ai_controller/controller, ability_key, target_key)
+ . = ..()
+ var/datum/weakref/weak_target = controller.blackboard[target_key]
+ var/mob/living/target = weak_target?.resolve()
+ if (QDELETED(target))
+ return FALSE
+ controller.current_movement_target = target
+
+/datum/ai_behavior/pet_use_ability/perform(delta_time, datum/ai_controller/controller, ability_key, target_key)
+ var/datum/action/cooldown/mob_cooldown/ability = controller.blackboard[ability_key]
+ var/datum/weakref/weak_target = controller.blackboard[target_key]
+ var/mob/living/target = weak_target?.resolve()
+ if (!ability || QDELETED(target))
+ finish_action(controller, FALSE, ability_key, target_key)
+ return
+ var/mob/pawn = controller.pawn
+ if (ability.InterceptClickOn(pawn, null, target))
+ finish_action(controller, TRUE, ability_key, target_key)
+
+/datum/ai_behavior/pet_use_ability/finish_action(datum/ai_controller/controller, succeeded, ability_key, target_key)
+ . = ..()
+ controller.blackboard[BB_ACTIVE_PET_COMMAND] = PET_COMMAND_IDLE // Wait for further instruction
+ controller.blackboard[target_key] = null
diff --git a/code/datums/ai/basic_mobs/targetting_datums/dont_target_friends.dm b/code/datums/ai/basic_mobs/targetting_datums/dont_target_friends.dm
new file mode 100644
index 00000000000..0fcd5dbf422
--- /dev/null
+++ b/code/datums/ai/basic_mobs/targetting_datums/dont_target_friends.dm
@@ -0,0 +1,30 @@
+/// Don't target an atom in our friends list (or turfs), anything else is fair game
+/datum/targetting_datum/not_friends
+
+///Returns true or false depending on if the target can be attacked by the mob
+/datum/targetting_datum/not_friends/can_attack(mob/living/living_mob, atom/target)
+ if (!target)
+ return FALSE
+ if (isturf(target))
+ return FALSE
+
+ if (ismob(target))
+ var/mob/mob_target = target
+ if (mob_target.status_flags & GODMODE)
+ return FALSE
+
+ if (living_mob.see_invisible < target.invisibility)
+ return FALSE
+ if (isturf(target.loc) && living_mob.z != target.z) // z check will always fail if target is in a mech
+ return FALSE
+
+ if (!living_mob.ai_controller) // How did you get here?
+ return FALSE
+
+ var/list/friends_list = living_mob.ai_controller.blackboard[BB_FRIENDS_LIST]
+ if (!friends_list)
+ return TRUE // We don't have any friends, anything's fair game
+ if (!friends_list[WEAKREF(target)])
+ return TRUE // This is not our friend, fire at will
+
+ return FALSE
diff --git a/code/datums/components/pet_commands/obeys_commands.dm b/code/datums/components/pet_commands/obeys_commands.dm
new file mode 100644
index 00000000000..8dc297e80dc
--- /dev/null
+++ b/code/datums/components/pet_commands/obeys_commands.dm
@@ -0,0 +1,70 @@
+/**
+ * # Obeys Commands Component
+ * Manages a list of pet command datums, allowing you to boss it around
+ * Creates a radial menu of pet commands when this creature is alt-clicked, if it has any
+ */
+/datum/component/obeys_commands
+ /// List of commands you can give to the owner of this component
+ var/list/available_commands = list()
+
+/// The available_commands parameter should be passed as a list of typepaths
+/datum/component/obeys_commands/Initialize(list/command_typepaths = list())
+ . = ..()
+ if (!isliving(parent))
+ return COMPONENT_INCOMPATIBLE
+ var/mob/living/living_parent = parent
+ if (!living_parent.ai_controller)
+ return COMPONENT_INCOMPATIBLE
+ if (!length(command_typepaths))
+ CRASH("Initialised obedience component with no commands.")
+
+ for (var/command_path in command_typepaths)
+ var/datum/pet_command/new_command = new command_path(parent)
+ available_commands[new_command.command_name] = new_command
+
+/datum/component/obeys_commands/Destroy(force, silent)
+ . = ..()
+ QDEL_NULL(available_commands)
+
+/datum/component/obeys_commands/RegisterWithParent()
+ RegisterSignal(parent, COMSIG_LIVING_BEFRIENDED, PROC_REF(add_friend))
+ RegisterSignal(parent, COMSIG_CLICK_ALT, PROC_REF(display_menu))
+
+/datum/component/obeys_commands/UnregisterFromParent()
+ UnregisterSignal(parent, list(COMSIG_LIVING_BEFRIENDED, COMSIG_CLICK_ALT))
+
+/datum/component/obeys_commands/proc/add_friend(datum/source, mob/living/new_friend)
+ SIGNAL_HANDLER
+
+ for (var/command_name as anything in available_commands)
+ var/datum/pet_command/command = available_commands[command_name]
+ INVOKE_ASYNC(command, TYPE_PROC_REF(/datum/pet_command, add_new_friend), new_friend)
+
+
+/// Displays a radial menu of commands
+/datum/component/obeys_commands/proc/display_menu(datum/source, mob/living/clicker)
+ SIGNAL_HANDLER
+
+ var/mob/living/living_parent = parent
+ if (IS_DEAD_OR_INCAP(living_parent))
+ return
+ if (!living_parent.ai_controller)
+ return
+ var/list/friends_list = living_parent.ai_controller.blackboard[BB_FRIENDS_LIST]
+ if (!friends_list || !friends_list[WEAKREF(clicker)])
+ return // Not our friend, can't boss us around
+
+ INVOKE_ASYNC(src, PROC_REF(display_radial_menu), clicker)
+
+/// Actually display the radial menu and then do something with the result
+/datum/component/obeys_commands/proc/display_radial_menu(mob/living/clicker)
+ var/list/radial_options = list()
+ for (var/command_name as anything in available_commands)
+ var/datum/pet_command/command = available_commands[command_name]
+ radial_options += command.provide_radial_data()
+
+ var/pick = show_radial_menu(clicker, clicker, radial_options, require_near = TRUE, tooltips = TRUE)
+ if (!pick)
+ return
+ var/datum/pet_command/picked_command = available_commands[pick]
+ picked_command.try_activate_command(clicker)
diff --git a/code/datums/components/pet_commands/pet_command.dm b/code/datums/components/pet_commands/pet_command.dm
new file mode 100644
index 00000000000..b732783f1d9
--- /dev/null
+++ b/code/datums/components/pet_commands/pet_command.dm
@@ -0,0 +1,143 @@
+/**
+ * # Pet Command
+ * Set some AI blackboard commands in response to receiving instructions
+ * This is abstract and should be extended for actual behaviour
+ */
+/datum/pet_command
+ /// Weak reference to who follows this command
+ var/datum/weakref/weak_parent
+ /// Key for command applied when you receive an order
+ var/command_key = PET_COMMAND_NONE
+ /// Unique name used for radial selection, should not be shared with other commands on one mob
+ var/command_name
+ /// Description to display in radial menu
+ var/command_desc
+ /// Icon to display in radial menu
+ var/icon/radial_icon
+ /// Icon state to display in radial menu
+ var/radial_icon_state
+ /// Speech strings to listen out for
+ var/list/speech_commands
+ /// Shown above the mob's head when it hears you
+ var/command_feedback
+ /// How close a mob needs to be to a target to respond to a command
+ var/sense_radius = 7
+
+/datum/pet_command/New(mob/living/parent)
+ . = ..()
+ weak_parent = WEAKREF(parent)
+ parent.ai_controller.blackboard[command_key] = WEAKREF(src)
+
+/// Register a new guy we want to listen to
+/datum/pet_command/proc/add_new_friend(mob/living/tamer)
+ RegisterSignal(tamer, COMSIG_MOB_SAY, PROC_REF(respond_to_command))
+
+/// Respond to something that one of our friends has asked us to do
+/datum/pet_command/proc/respond_to_command(mob/living/speaker, speech_args)
+ SIGNAL_HANDLER
+
+ var/mob/living/parent = weak_parent.resolve()
+ if (!parent)
+ return
+ if (!can_see(parent, speaker, sense_radius)) // Basically the same rules as hearing
+ return
+
+ var/spoken_text = speech_args[SPEECH_MESSAGE]
+ if (!find_command_in_text(spoken_text))
+ return
+
+ try_activate_command(speaker)
+
+/// Returns true if we find any of our spoken commands in the text
+/datum/pet_command/proc/find_command_in_text(spoken_text)
+ for (var/command as anything in speech_commands)
+ if (!findtext(spoken_text, command))
+ continue
+ return TRUE
+ return FALSE
+
+/// Apply a command state if conditions are right, return command if successful
+/datum/pet_command/proc/try_activate_command(mob/living/commander)
+ var/mob/living/parent = weak_parent.resolve()
+ if (!parent)
+ return
+ if (!parent.ai_controller) // We stopped having a brain at some point
+ return
+ if (IS_DEAD_OR_INCAP(parent)) // Probably can't hear them if we're dead
+ return
+ if (parent.ai_controller.blackboard[BB_ACTIVE_PET_COMMAND] == command_key) // We're already doing it
+ return
+ set_command_active(parent, commander)
+
+/// Activate the command, extend to add visible messages and the like
+/datum/pet_command/proc/set_command_active(mob/living/parent, mob/living/commander)
+ set_command_target(parent, null)
+
+ parent.ai_controller.CancelActions() // Stop whatever you're doing and do this instead
+ parent.ai_controller.blackboard[BB_ACTIVE_PET_COMMAND] = command_key
+ if (command_feedback)
+ parent.balloon_alert_to_viewers("[command_feedback]") // If we get a nicer runechat way to do this, refactor this
+
+/// Store the target for the AI blackboard
+/datum/pet_command/proc/set_command_target(mob/living/parent, atom/target)
+ parent.ai_controller.blackboard[BB_CURRENT_PET_TARGET] = WEAKREF(target)
+
+/// Provide information about how to display this command in a radial menu
+/datum/pet_command/proc/provide_radial_data()
+ var/datum/radial_menu_choice/choice = new()
+ choice.name = command_name
+ choice.image = icon(icon = radial_icon, icon_state = radial_icon_state)
+ var/tooltip = command_desc
+ if (length(speech_commands))
+ tooltip += "
Speak this command with the words [speech_commands.Join(", ")]."
+ choice.info = tooltip
+
+ return list("[command_name]" = choice)
+
+/**
+ * Execute an AI action on the provided controller, what we should actually do when this command is active.
+ * This should basically always be called from a planning subtree which passes its own controller.
+ * Return SUBTREE_RETURN_FINISH_PLANNING to pass that instruction on to the controller, or don't if you don't want that.
+ */
+/datum/pet_command/proc/execute_action(datum/ai_controller/controller)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("Pet command execute action not implemented.")
+
+/**
+ * # Point Targetting Pet Command
+ * As above but also listens for you pointing at something and marks it as a target
+ */
+/datum/pet_command/point_targetting
+ /// Text describing an action we perform upon receiving a new target
+ var/pointed_reaction
+ /// Blackboard key for targetting datum, this is likely going to need it
+ var/targetting_datum_key = BB_PET_TARGETTING_DATUM
+
+/datum/pet_command/point_targetting/add_new_friend(mob/living/tamer)
+ . = ..()
+ RegisterSignal(tamer, COMSIG_MOB_POINTED, PROC_REF(look_for_target))
+
+/// Target the pointed atom for actions
+/datum/pet_command/point_targetting/proc/look_for_target(mob/living/friend, atom/pointed_atom)
+ SIGNAL_HANDLER
+
+
+ var/mob/living/parent = weak_parent.resolve()
+ if (!parent)
+ return
+ if (!parent.ai_controller)
+ return
+ if (IS_DEAD_OR_INCAP(parent))
+ return
+ if (parent.ai_controller.blackboard[BB_ACTIVE_PET_COMMAND] != command_key) // We're not listening right now
+ return
+ if (parent.ai_controller.blackboard[BB_CURRENT_PET_TARGET] == WEAKREF(pointed_atom)) // That's already our target
+ return
+ if (!can_see(parent, pointed_atom, sense_radius))
+ return
+
+ parent.ai_controller.CancelActions()
+ // Deciding if they can actually do anything with this target is the behaviour's job
+ set_command_target(parent, pointed_atom)
+ // These are usually hostile actions so should have a record in chat
+ parent.visible_message(span_warning("[parent] follows [friend]'s gesture towards [pointed_atom] and [pointed_reaction]!"))
diff --git a/code/datums/components/pet_commands/pet_commands_basic.dm b/code/datums/components/pet_commands/pet_commands_basic.dm
new file mode 100644
index 00000000000..7caad390e06
--- /dev/null
+++ b/code/datums/components/pet_commands/pet_commands_basic.dm
@@ -0,0 +1,124 @@
+// None of these are really complex enough to merit their own file
+
+/**
+ * # Pet Command: Idle
+ * Tells a pet to resume its idle behaviour, usually staying put where you leave it
+ */
+/datum/pet_command/idle
+ command_name = "Stay"
+ command_desc = "Command your pet to stay idle in this location."
+ radial_icon = 'icons/testing/turf_analysis.dmi'
+ radial_icon_state = "red_arrow"
+ command_key = PET_COMMAND_IDLE
+ speech_commands = list("sit", "stay", "stop")
+ command_feedback = "sits"
+
+/datum/pet_command/idle/execute_action(datum/ai_controller/controller)
+ return SUBTREE_RETURN_FINISH_PLANNING // This cancels further AI planning
+
+/**
+ * # Pet Command: Stop
+ * Tells a pet to exit command mode and resume its normal behaviour, which includes regular target-seeking and what have you
+ */
+/datum/pet_command/free
+ command_name = "Loose"
+ command_desc = "Allow your pet to resume its natural behaviours."
+ radial_icon = 'icons/mob/actions/actions_spells.dmi'
+ radial_icon_state = "repulse"
+ command_key = PET_COMMAND_NONE
+ speech_commands = list("free", "loose")
+ command_feedback = "relaxes"
+
+/datum/pet_command/free/execute_action(datum/ai_controller/controller)
+ return // Just move on to the next planning subtree.
+
+/**
+ * # Pet Command: Follow
+ * Tells a pet to follow you until you tell it to do something else
+ */
+/datum/pet_command/follow
+ command_name = "Follow"
+ command_desc = "Command your pet to accompany you."
+ radial_icon = 'icons/mob/actions/actions_spells.dmi'
+ radial_icon_state = "summons"
+ command_key = PET_COMMAND_FOLLOW
+ speech_commands = list("heel", "follow")
+
+/datum/pet_command/follow/set_command_active(mob/living/parent, mob/living/commander)
+ . = ..()
+ set_command_target(parent, commander)
+
+/datum/pet_command/follow/execute_action(datum/ai_controller/controller)
+ controller.queue_behavior(/datum/ai_behavior/pet_follow_friend, BB_CURRENT_PET_TARGET)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+/**
+ * # Pet Command: Attack
+ * Tells a pet to chase and bite the next thing you point at
+ */
+/datum/pet_command/point_targetting/attack
+ command_name = "Attack"
+ command_desc = "Command your pet to attack things that you point out to it."
+ radial_icon = 'icons/effects/effects.dmi'
+ radial_icon_state = "bite"
+
+ command_key = PET_COMMAND_ATTACK
+ speech_commands = list("attack", "sic", "kill")
+ command_feedback = "growl"
+ pointed_reaction = "growls"
+ /// Balloon alert to display if providing an invalid target
+ var/refuse_reaction = "shakes head"
+ /// Attack behaviour to use, generally you will want to override this to add some kind of cooldown
+ var/attack_behaviour = /datum/ai_behavior/basic_melee_attack
+
+// Refuse to target things we can't target, chiefly other friends
+/datum/pet_command/point_targetting/attack/set_command_target(mob/living/parent, atom/target)
+ if (!target)
+ return
+ var/mob/living/living_parent = parent
+ if (!living_parent.ai_controller)
+ return
+ var/datum/targetting_datum/targeter = living_parent.ai_controller.blackboard[targetting_datum_key]
+ if (!targeter)
+ return
+ if (!targeter.can_attack(living_parent, target))
+ refuse_target(parent, target)
+ return
+ return ..()
+
+/// Display feedback about not targetting something
+/datum/pet_command/point_targetting/attack/proc/refuse_target(mob/living/parent, atom/target)
+ var/mob/living/living_parent = parent
+ living_parent.balloon_alert_to_viewers("[refuse_reaction]")
+ living_parent.visible_message(span_notice("[living_parent] refuses to attack [target]."))
+
+/datum/pet_command/point_targetting/attack/execute_action(datum/ai_controller/controller)
+ controller.queue_behavior(attack_behaviour, BB_CURRENT_PET_TARGET, targetting_datum_key)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+/**
+ * # Pet Command: Targetted Ability
+ * Tells a pet to use some kind of ability on the next thing you point at
+ */
+/datum/pet_command/point_targetting/use_ability
+ command_name = "Use ability"
+ command_desc = "Command your pet to use one of its special skills on something that you point out to it."
+ radial_icon = 'icons/mob/actions/actions_spells.dmi'
+ radial_icon_state = "projectile"
+ command_key = PET_COMMAND_USE_ABILITY
+ speech_commands = list("shoot", "blast", "cast")
+ command_feedback = "growl"
+ pointed_reaction = "growls"
+ /// Blackboard key where a reference to some kind of mob ability is stored
+ var/pet_ability_key
+
+/datum/pet_command/point_targetting/use_ability/execute_action(datum/ai_controller/controller)
+ if (!pet_ability_key)
+ return
+ var/datum/action/cooldown/using_action = controller.blackboard[pet_ability_key]
+ if (QDELETED(using_action))
+ return
+ // We don't check if the target exists because we want to 'sit attentively' if we've been instructed to attack but not given one yet
+ // We also don't check if the cooldown is over because there's no way a pet owner can know that, the behaviour will handle it
+ controller.queue_behavior(/datum/ai_behavior/pet_use_ability, pet_ability_key, targetting_datum_key)
+ return SUBTREE_RETURN_FINISH_PLANNING
diff --git a/code/datums/components/squeak.dm b/code/datums/components/squeak.dm
index 23f6d5b28b8..41bc0c8dcb0 100644
--- a/code/datums/components/squeak.dm
+++ b/code/datums/components/squeak.dm
@@ -97,6 +97,8 @@
return
if(arrived.movement_type & (FLYING|FLOATING) || !arrived.has_gravity())
return
+ if(ismob(arrived) && !arrived.density) // Prevents 10 overlapping mice from making an unholy sound while moving
+ return
var/atom/current_parent = parent
if(isturf(current_parent?.loc))
play_squeak()
diff --git a/code/datums/components/tameable.dm b/code/datums/components/tameable.dm
index 5c85c61f2d7..b7eb5890af7 100644
--- a/code/datums/components/tameable.dm
+++ b/code/datums/components/tameable.dm
@@ -56,9 +56,8 @@
after_tame?.Invoke(tamer)//Run custom behavior if needed
- if(ishostile(parent) && isliving(tamer)) //Kinda shit check but this only applies to hostiles atm
- var/mob/living/simple_animal/hostile/evil_but_now_not_evil = parent
- evil_but_now_not_evil.friends = tamer
- evil_but_now_not_evil.faction = tamer.faction.Copy()
+ if (isliving(parent) && isliving(tamer))
+ var/mob/living/tamed = parent
+ INVOKE_ASYNC(tamed, TYPE_PROC_REF(/mob/living, befriend), tamer)
qdel(src)
diff --git a/code/modules/mob/living/basic/vermin/cockroach.dm b/code/modules/mob/living/basic/vermin/cockroach.dm
index 71b1fbf67f2..0d51b92abfb 100644
--- a/code/modules/mob/living/basic/vermin/cockroach.dm
+++ b/code/modules/mob/living/basic/vermin/cockroach.dm
@@ -53,7 +53,8 @@
/datum/ai_controller/basic_controller/cockroach
blackboard = list(
- BB_TARGETTING_DATUM = new /datum/targetting_datum/basic()
+ BB_TARGETTING_DATUM = new /datum/targetting_datum/basic(),
+ BB_PET_TARGETTING_DATUM = new /datum/targetting_datum/not_friends(),
)
ai_traits = STOP_MOVING_WHEN_PULLED
@@ -92,6 +93,7 @@
/datum/ai_controller/basic_controller/cockroach/glockroach
planning_subtrees = list(
+ /datum/ai_planning_subtree/pet_planning,
/datum/ai_planning_subtree/random_speech/cockroach,
/datum/ai_planning_subtree/simple_find_target,
/datum/ai_planning_subtree/basic_ranged_attack_subtree/glockroach, //If we are attacking someone, this will prevent us from hunting
@@ -137,6 +139,7 @@
return FALSE
/datum/ai_controller/basic_controller/cockroach/hauberoach
planning_subtrees = list(
+ /datum/ai_planning_subtree/pet_planning,
/datum/ai_planning_subtree/random_speech/cockroach,
/datum/ai_planning_subtree/simple_find_target,
/datum/ai_planning_subtree/basic_melee_attack_subtree/hauberoach, //If we are attacking someone, this will prevent us from hunting
@@ -151,6 +154,7 @@
/datum/ai_controller/basic_controller/cockroach/sewer
planning_subtrees = list(
+ /datum/ai_planning_subtree/pet_planning,
/datum/ai_planning_subtree/random_speech/cockroach,
/datum/ai_planning_subtree/simple_find_target,
/datum/ai_planning_subtree/basic_melee_attack_subtree/sewer,
diff --git a/code/modules/mob/living/basic/vermin/mouse.dm b/code/modules/mob/living/basic/vermin/mouse.dm
index 0ed42ffb28a..906ad668421 100644
--- a/code/modules/mob/living/basic/vermin/mouse.dm
+++ b/code/modules/mob/living/basic/vermin/mouse.dm
@@ -346,6 +346,7 @@
/datum/ai_controller/basic_controller/mouse/rat
blackboard = list(
BB_TARGETTING_DATUM = new /datum/targetting_datum/basic(),
+ BB_PET_TARGETTING_DATUM = new /datum/targetting_datum/not_friends(),
BB_BASIC_MOB_CURRENT_TARGET = null, // heathen
BB_CURRENT_HUNTING_TARGET = null, // cheese
BB_LOW_PRIORITY_HUNTING_TARGET = null, // cable
@@ -355,6 +356,7 @@
ai_movement = /datum/ai_movement/basic_avoidance
idle_behavior = /datum/idle_behavior/idle_random_walk
planning_subtrees = list(
+ /datum/ai_planning_subtree/pet_planning,
/datum/ai_planning_subtree/simple_find_target,
/datum/ai_planning_subtree/basic_melee_attack_subtree/rat,
/datum/ai_planning_subtree/find_and_hunt_target/look_for_cheese,
diff --git a/code/modules/mob/living/living.dm b/code/modules/mob/living/living.dm
index f9314b64d8b..6c9d0cd71f7 100644
--- a/code/modules/mob/living/living.dm
+++ b/code/modules/mob/living/living.dm
@@ -2404,6 +2404,18 @@ GLOBAL_LIST_EMPTY(fire_appearances)
. = ..()
add_mood_event("gaming", /datum/mood_event/gaming)
+/// Proc for giving a mob a new 'friend', generally used for AI control and targetting
+/mob/living/proc/befriend(mob/living/new_friend)
+ SHOULD_CALL_PARENT(TRUE)
+ faction |= REF(new_friend)
+ if (ai_controller)
+ var/list/friends = ai_controller.blackboard[BB_FRIENDS_LIST]
+ if (!friends)
+ friends = list()
+ friends[WEAKREF(new_friend)] = TRUE
+ ai_controller.blackboard[BB_FRIENDS_LIST] = friends
+ SEND_SIGNAL(src, COMSIG_LIVING_BEFRIENDED, new_friend)
+
/// Admin only proc for making the mob hallucinate a certain thing
/mob/living/proc/admin_give_hallucination(mob/admin)
if(!admin || !check_rights(NONE))
diff --git a/code/modules/mob/living/simple_animal/hostile/hostile.dm b/code/modules/mob/living/simple_animal/hostile/hostile.dm
index f3a0902a1b8..be9cd078bc2 100644
--- a/code/modules/mob/living/simple_animal/hostile/hostile.dm
+++ b/code/modules/mob/living/simple_animal/hostile/hostile.dm
@@ -664,3 +664,8 @@
target = new_target
if(target)
RegisterSignal(target, COMSIG_PARENT_QDELETING, PROC_REF(handle_target_del))
+
+/mob/living/simple_animal/hostile/befriend(mob/living/new_friend)
+ . = ..()
+ friends += new_friend
+ faction = new_friend.faction.Copy()
diff --git a/code/modules/mob/living/simple_animal/hostile/regalrat.dm b/code/modules/mob/living/simple_animal/hostile/regalrat.dm
index 9ef954d3aa6..785fbe41272 100644
--- a/code/modules/mob/living/simple_animal/hostile/regalrat.dm
+++ b/code/modules/mob/living/simple_animal/hostile/regalrat.dm
@@ -229,8 +229,8 @@
StartCooldown()
/**
- * This action checks all nearby mice, and converts them into hostile rats.
- * If no mice are nearby, creates a new one.
+ * This action checks some nearby maintenance animals and makes them your minions.
+ * If none are nearby, creates a new mouse.
*/
/datum/action/cooldown/riot
name = "Raise Army"
@@ -242,111 +242,158 @@
overlay_icon_state = "bg_clock_border"
cooldown_time = 8 SECONDS
melee_cooldown_time = 0 SECONDS
+ /// How close does something need to be for us to recruit it?
+ var/range = 5
+ /// Commands you can give to your mouse army
+ var/static/list/mouse_commands = list(
+ /datum/pet_command/idle,
+ /datum/pet_command/free,
+ /datum/pet_command/follow,
+ /datum/pet_command/point_targetting/attack/mouse
+ )
+ /// Commands you can give to glockroaches
+ var/static/list/glockroach_commands = list(
+ /datum/pet_command/idle,
+ /datum/pet_command/free,
+ /datum/pet_command/follow,
+ /datum/pet_command/point_targetting/attack/glockroach
+ )
/datum/action/cooldown/riot/Activate(atom/target)
StartCooldown(10 SECONDS)
riot()
StartCooldown()
+/**
+ * Attempts to, in order and ending at any successful step:
+ * * Convert nearby mice into aggressive rats.
+ * * Convert nearby roaches into aggressive roaches.
+ * * Convert nearby frogs into aggressive frogs.
+ * * Spawn a single mouse if below the mouse cap.
+ */
/datum/action/cooldown/riot/proc/riot()
- var/cap = CONFIG_GET(number/ratcap)
var/uplifted_mice = FALSE
- var/uplifted_frog = FALSE
+ for (var/mob/living/basic/mouse/nearby_mouse in oview(owner, range))
+ uplifted_mice = convert_mouse(nearby_mouse) || uplifted_mice
+ if (uplifted_mice)
+ owner.visible_message(span_warning("[owner] commands their army to action, mutating them into rats!"))
+ return
+
+ var/static/list/converted_check_list = list(FACTION_RAT)
var/uplifted_roach = FALSE
- var/list/converted_check_list = list(FACTION_RAT)
- for(var/mob/living/simple_animal/hostile/retaliate/frog/nearby_frog in oview(owner, 5))
- // No need to convert when not on the same team.
- if(faction_check(nearby_frog.faction, converted_check_list))
- continue
- if(nearby_frog.name == "frog")
- nearby_frog.name = "trash frog"
- nearby_frog.icon_state += "_trash"
- nearby_frog.icon_living += "_trash"
- nearby_frog.icon_dead = nearby_frog.icon_state + "_dead"
- else if(nearby_frog.name == "rare frog")
- nearby_frog.name = "rare trash frog"
- nearby_frog.icon_state += "_trash"
- nearby_frog.icon_living += "_trash"
- nearby_frog.icon_dead = nearby_frog.icon_state + "_dead"
- nearby_frog.desc += " ...Except this one lives in a trash bag."
- nearby_frog.maxHealth += 10
- nearby_frog.health += 10
- nearby_frog.melee_damage_lower += 1
- nearby_frog.melee_damage_upper += 5
- nearby_frog.faction = owner.faction.Copy()
- uplifted_frog = TRUE
- break
-
- if(uplifted_frog)
- owner.visible_message(span_warning("[owner] commands their army to action, mutating them trash frogs!"))
- else
- for(var/mob/living/basic/cockroach/nearby_roach in oview(owner, 5))
- // No need to convert when not on the same team.
- if(faction_check(nearby_roach.faction, converted_check_list))
- continue
- if(istype(nearby_roach, /mob/living/basic/cockroach/glockroach))
- if(nearby_roach.name == "glockroach")
- nearby_roach.name = "sewer glockroach"
- nearby_roach.melee_damage_lower += 0.5
- nearby_roach.melee_damage_upper += 2
- else if(istype(nearby_roach, /mob/living/basic/cockroach/hauberoach))
- if(nearby_roach.name == "hauberoach")
- nearby_roach.name = "sewer hauberoach"
- nearby_roach.melee_damage_lower += 0.5
- nearby_roach.melee_damage_upper += 2
- else
- if(nearby_roach.name == "cockroach")
- nearby_roach.name = "sewer cockroach"
- nearby_roach.melee_damage_lower += 2
- nearby_roach.melee_damage_upper += 4
- nearby_roach.obj_damage += 5
- nearby_roach.ai_controller = new /datum/ai_controller/basic_controller/cockroach/sewer(nearby_roach)
- nearby_roach.desc += " ...Except this one looks very robust."
- nearby_roach.icon_state += "_sewer"
- nearby_roach.maxHealth += 1
- nearby_roach.health += 1
- nearby_roach.faction = owner.faction.Copy()
- uplifted_roach = TRUE
- break
-
- if(uplifted_roach)
+ for (var/mob/living/basic/cockroach/nearby_roach in oview(owner, range))
+ uplifted_roach = convert_roach(nearby_roach, converted_check_list) || uplifted_roach
+ if (uplifted_roach)
owner.visible_message(span_warning("[owner] commands their army to action, mutating them into sewer roaches!"))
- else if(!uplifted_frog)
- for(var/mob/living/basic/mouse/nearby_mouse in oview(owner, 5))
- // This mouse is already rat controlled, let's not bother with it.
- if(istype(nearby_mouse.ai_controller, /datum/ai_controller/basic_controller/mouse/rat))
- continue
- var/mob/living/basic/mouse/rat/rat_path = /mob/living/basic/mouse/rat
+ return
- // Change name
- if(nearby_mouse.name == "mouse")
- nearby_mouse.name = initial(rat_path.name)
- // Buffs our combat stats to that of a rat
- nearby_mouse.melee_damage_lower = initial(rat_path.melee_damage_lower)
- nearby_mouse.melee_damage_upper = initial(rat_path.melee_damage_upper)
- nearby_mouse.obj_damage = initial(rat_path.obj_damage)
- nearby_mouse.maxHealth = initial(rat_path.maxHealth)
- nearby_mouse.health = initial(rat_path.health)
- // Replace our AI with a rat one
- nearby_mouse.ai_controller = new /datum/ai_controller/basic_controller/mouse/rat(nearby_mouse)
- // Give a hint in description too
- nearby_mouse.desc += " ...Except this one looks corrupted and aggressive."
- // Now we share factions!
- nearby_mouse.faction = owner.faction.Copy()
- uplifted_mice = TRUE
- break
+ var/uplifted_frog = FALSE
+ for (var/mob/living/simple_animal/hostile/retaliate/frog/nearby_frog in oview(owner, range))
+ uplifted_frog = convert_frog(nearby_frog, converted_check_list) || uplifted_frog
+ if (uplifted_frog)
+ owner.visible_message(span_warning("[owner] commands their army to action, mutating them into trash frogs!"))
+ return
- if(uplifted_mice)
- owner.visible_message(span_warning("[owner] commands their army to action, mutating them into rats!"))
+ var/rat_cap = CONFIG_GET(number/ratcap)
+ if (LAZYLEN(SSmobs.cheeserats) >= rat_cap)
+ to_chat(owner,span_warning("There's too many mice on this station to beckon a new one! Find them first!"))
+ return
+ new /mob/living/basic/mouse(owner.loc)
+ owner.visible_message(span_warning("[owner] commands a rat to their side!"))
- else if(LAZYLEN(SSmobs.cheeserats) < cap)
- new /mob/living/basic/mouse(owner.loc)
- owner.visible_message(span_warning("[owner] commands a rat to their side!"))
+/// Makes a passed mob into our minion
+/datum/action/cooldown/riot/proc/make_minion(mob/living/new_minion, minion_desc, list/command_list = mouse_commands)
+ if (isbasicmob(new_minion)) // One day this will work for frogs too
+ new_minion.AddComponent(/datum/component/obeys_commands, command_list)
+ new_minion.befriend(owner)
+ new_minion.faction = owner.faction.Copy()
+ // Give a hint in description too
+ new_minion.desc += minion_desc
+ new_minion.balloon_alert_to_viewers("squeak")
- else
- to_chat(owner,span_warning("There's too many mice on this station to beckon a new one! Find them first!"))
+/// Turns a mouse into an angry mouse
+/datum/action/cooldown/riot/proc/convert_mouse(mob/living/basic/mouse/nearby_mouse)
+ // This mouse is already rat controlled, let's not bother with it.
+ if (istype(nearby_mouse.ai_controller, /datum/ai_controller/basic_controller/mouse/rat))
+ return FALSE
- StartCooldown()
+ var/mob/living/basic/mouse/rat/rat_path = /mob/living/basic/mouse/rat
+ // Change name
+ if (nearby_mouse.name == "mouse")
+ nearby_mouse.name = initial(rat_path.name)
+ // Buffs our combat stats to that of a rat
+ nearby_mouse.melee_damage_lower = initial(rat_path.melee_damage_lower)
+ nearby_mouse.melee_damage_upper = initial(rat_path.melee_damage_upper)
+ nearby_mouse.obj_damage = initial(rat_path.obj_damage)
+ nearby_mouse.maxHealth = initial(rat_path.maxHealth)
+ nearby_mouse.health = initial(rat_path.health)
+ // Replace our AI with a rat one
+ nearby_mouse.ai_controller = new /datum/ai_controller/basic_controller/mouse/rat(nearby_mouse)
+ make_minion(nearby_mouse, " ...Except this one looks corrupted and aggressive.")
+ return TRUE
+
+/// Turns a roach into an angry roach
+/datum/action/cooldown/riot/proc/convert_roach(mob/living/basic/cockroach/nearby_roach, list/converted_check_list)
+ // No need to convert when not on the same team.
+ if (faction_check(nearby_roach.faction, converted_check_list))
+ return FALSE
+
+ var/list/minion_commands = mouse_commands
+ if (!findtext(nearby_roach.name, "sewer"))
+ nearby_roach.name = "sewer [nearby_roach.name]"
+
+ if (istype(nearby_roach, /mob/living/basic/cockroach/glockroach) || istype(nearby_roach, /mob/living/basic/cockroach/hauberoach))
+ if (istype(nearby_roach, /mob/living/basic/cockroach/glockroach))
+ minion_commands = glockroach_commands
+ nearby_roach.melee_damage_lower += 0.5
+ nearby_roach.melee_damage_upper += 2
+ else
+ nearby_roach.melee_damage_lower += 2
+ nearby_roach.melee_damage_upper += 4
+ nearby_roach.obj_damage += 5
+ nearby_roach.ai_controller = new /datum/ai_controller/basic_controller/cockroach/sewer(nearby_roach)
+
+ nearby_roach.icon_state += "_sewer"
+ nearby_roach.maxHealth += 1
+ nearby_roach.health += 1
+ make_minion(nearby_roach, "
This one looks extra robust.", minion_commands)
+ return TRUE
+
+/// Turns a frog into a crazy frog. This doesn't do anything interesting and should when it becomes a basic mob.
+/datum/action/cooldown/riot/proc/convert_frog(mob/living/simple_animal/hostile/retaliate/frog/nearby_frog, list/converted_check_list)
+ // No need to convert when not on the same team.
+ if(faction_check(nearby_frog.faction, converted_check_list))
+ return FALSE
+
+ if (!findtext(nearby_frog.name, "trash"))
+ nearby_frog.name = replacetext(nearby_frog.name, "frog", "trash frog")
+
+ nearby_frog.icon_state += "_trash"
+ nearby_frog.icon_living += "_trash"
+ nearby_frog.icon_dead = nearby_frog.icon_state + "_dead"
+ nearby_frog.maxHealth += 10
+ nearby_frog.health += 10
+ nearby_frog.melee_damage_lower += 1
+ nearby_frog.melee_damage_upper += 5
+ var/crazy_frog_desc = " ...[findtext(nearby_frog.name, "rare") ? "even though" : "perhaps because"] they live in a trash bag."
+ make_minion(nearby_frog, crazy_frog_desc)
+ return TRUE
+
+// Command you can give to a mouse to make it kill someone
+/datum/pet_command/point_targetting/attack/mouse
+ speech_commands = list("attack", "sic", "kill", "cheese em")
+ command_feedback = "squeak!" // Frogs and roaches can squeak too it's fine
+ pointed_reaction = "squeaks aggressively!"
+ refuse_reaction = "quivers"
+ attack_behaviour = /datum/ai_behavior/basic_melee_attack/rat
+
+// Command you can give to a mouse to make it kill someone
+/datum/pet_command/point_targetting/attack/glockroach
+ speech_commands = list("attack", "sic", "kill", "cheese em")
+ command_feedback = "squeak!"
+ pointed_reaction = "cocks gun"
+ refuse_reaction = "quivers"
+ attack_behaviour = /datum/ai_behavior/basic_ranged_attack/glockroach
/**
*Spittle; harmless reagent that is added by rat king, and makes you disgusted.
diff --git a/tgstation.dme b/tgstation.dme
index a39550703fb..975ebf04eac 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -33,6 +33,7 @@
#include "code\__DEFINES\admin.dm"
#include "code\__DEFINES\adventure.dm"
#include "code\__DEFINES\ai.dm"
+#include "code\__DEFINES\ai_pet_commands.dm"
#include "code\__DEFINES\alarm.dm"
#include "code\__DEFINES\alerts.dm"
#include "code\__DEFINES\antagonists.dm"
@@ -724,7 +725,11 @@
#include "code\datums\ai\basic_mobs\basic_subtrees\speech_subtree.dm"
#include "code\datums\ai\basic_mobs\basic_subtrees\target_retaliate.dm"
#include "code\datums\ai\basic_mobs\basic_subtrees\tipped_subtree.dm"
+#include "code\datums\ai\basic_mobs\pet_commands\pet_command_planning.dm"
+#include "code\datums\ai\basic_mobs\pet_commands\pet_follow_friend.dm"
+#include "code\datums\ai\basic_mobs\pet_commands\pet_use_targetted_ability.dm"
#include "code\datums\ai\basic_mobs\targetting_datums\basic_targetting_datum.dm"
+#include "code\datums\ai\basic_mobs\targetting_datums\dont_target_friends.dm"
#include "code\datums\ai\cursed\cursed_behaviors.dm"
#include "code\datums\ai\cursed\cursed_controller.dm"
#include "code\datums\ai\cursed\cursed_subtrees.dm"
@@ -960,6 +965,9 @@
#include "code\datums\components\food\decomposition.dm"
#include "code\datums\components\food\edible.dm"
#include "code\datums\components\food\ice_cream_holder.dm"
+#include "code\datums\components\pet_commands\obeys_commands.dm"
+#include "code\datums\components\pet_commands\pet_command.dm"
+#include "code\datums\components\pet_commands\pet_commands_basic.dm"
#include "code\datums\components\plumbing\_plumbing.dm"
#include "code\datums\components\plumbing\chemical_acclimator.dm"
#include "code\datums\components\plumbing\filter.dm"