Repository: tgstation/TerraGov-Marine-Corps
Pull request #17889: Builder npc
Author: login: Lumipharon
Labels: name: Feature
description: New interesting mechanics with new interesting bugs
Created: 2025-05-15T23:42:50Z | Merged: 2025-05-18T21:25:01Z

## About The Pull Request
Enables NPC's to build holo designators if they have the right supplies and skills.

Engineer NPC times baby.


https://github.com/user-attachments/assets/d38a0b13-3824-4475-8b2c-0c5b482b2e38


Other engineering stuff will be in a later PR, for now this is specifically holo designator building only.
## Why It's Good For The Game
Steal more player jobs so we can finally ban them.
## Changelog
:cl:
add: Engineer NPC's can build via holo designators
/:cl:



Diff:
diff --git a/code/__DEFINES/ai.dm b/code/__DEFINES/ai.dm
index 04cf6159c9750..997329c3871f0 100644
--- a/code/__DEFINES/ai.dm
+++ b/code/__DEFINES/ai.dm
@@ -105,8 +105,11 @@ GLOBAL_LIST_EMPTY(nodes_with_construction)
 #define HUMAN_AI_HEALING (1<<2)
 ///Healing self
 #define HUMAN_AI_SELF_HEALING (1<<3)
+///Building something
+#define HUMAN_AI_BUILDING (1<<4)
 
-#define HUMAN_AI_ANY_HEALING (HUMAN_AI_HEALING|HUMAN_AI_SELF_HEALING)
+///Any action that we generally don't want to interrupt
+#define HUMAN_AI_BUSY_ACTION (HUMAN_AI_HEALING|HUMAN_AI_SELF_HEALING|HUMAN_AI_BUILDING)
 
 ///We're good to shoot
 #define AI_FIRE_CAN_HIT (1<<0)
diff --git a/code/datums/outfits/npc_marines.dm b/code/datums/outfits/npc_marines.dm
index e081bdbbd121d..e9de71276ae51 100644
--- a/code/datums/outfits/npc_marines.dm
+++ b/code/datums/outfits/npc_marines.dm
@@ -248,3 +248,42 @@
 		/obj/item/explosive/grenade/smokebomb/cloak = 1,
 		/obj/item/explosive/grenade/smokebomb/antigas = 1,
 	)
+
+//
+/datum/outfit/job/npc/tgmc/squad_engineer
+	name = "NPC TGMC squad engineer"
+	jobtype = /datum/job/terragov/squad/engineer
+
+	id = /obj/item/card/id/dogtag
+	wear_suit = /obj/item/clothing/suit/modular/xenonauten/heavy/mimirengi
+	glasses = /obj/item/clothing/glasses/welding
+	w_uniform = /obj/item/clothing/under/marine/engineer/black_vest
+	mask = /obj/item/clothing/mask/gas
+	head = /obj/item/clothing/head/modular/m10x/mimir
+	back = /obj/item/storage/backpack/marine/engineerpack
+	belt = /obj/item/storage/belt/marine
+	gloves = /obj/item/clothing/gloves/marine/insulated
+	l_pocket = /obj/item/storage/pouch/medkit/firstaid
+	r_pocket = /obj/item/storage/pouch/tools/full
+	suit_store = /obj/item/weapon/gun/rifle/tx11/freelancerone
+	ears = /obj/item/radio/headset/mainship/marine
+	shoes = /obj/item/clothing/shoes/marine/full
+
+	backpack_contents = list(
+		/obj/item/storage/box/MRE = 1,
+		/obj/item/stack/sheet/plasteel/large_stack = 1,
+		/obj/item/storage/box/m94 = 1,
+		/obj/item/ammo_magazine/rifle/tx11 = 2,
+		/obj/item/reagent_containers/hypospray/autoinjector/inaprovaline = 2,
+	)
+	suit_contents = list(
+		/obj/item/stack/sheet/metal/large_stack = 4,
+		/obj/item/stack/sheet/plasteel/large_stack = 1,
+	)
+	belt_contents = list(/obj/item/ammo_magazine/rifle/tx11 = 6)
+	head_contents = list(/obj/item/reagent_containers/hypospray/autoinjector/combat_advanced = 2)
+	webbing_contents = list(
+		/obj/item/explosive/grenade/m15 = 2,
+		/obj/item/explosive/grenade/smokebomb/cloak = 1,
+		/obj/item/explosive/grenade/smokebomb/antigas = 2,
+	)
diff --git a/code/game/objects/items/stacks/stack.dm b/code/game/objects/items/stacks/stack.dm
index 345549e3bfe37..fd9ee6583f2d2 100644
--- a/code/game/objects/items/stacks/stack.dm
+++ b/code/game/objects/items/stacks/stack.dm
@@ -197,8 +197,8 @@
 
 
 /// Creates multiplier amount of objects based off of stack recipe recipe. Most creation variables are changed through stack recipe datum's variables
-/obj/item/stack/proc/create_object(mob/user, datum/stack_recipe/recipe, multiplier, turf/build_loc, build_dir)
-	if(user.get_active_held_item() != src)
+/obj/item/stack/proc/create_object(mob/user, datum/stack_recipe/recipe, multiplier, turf/build_loc, build_dir, ignore_stack_loc = FALSE)
+	if(!ignore_stack_loc && user.get_active_held_item() != src)
 		return
 	if(!can_interact(user))
 		return //TRUE
diff --git a/code/modules/ai/ai_behaviors/human_mobs/engineering.dm b/code/modules/ai/ai_behaviors/human_mobs/engineering.dm
new file mode 100644
index 0000000000000..0c58537f8d674
--- /dev/null
+++ b/code/modules/ai/ai_behaviors/human_mobs/engineering.dm
@@ -0,0 +1,98 @@
+/datum/ai_behavior/human
+	///A list of engineering related actions
+	var/list/engineering_list = list()
+	///Chat lines for trying to build
+	var/list/building_chat = list("Building.", "Building, cover me!", "Give me some cover!", "Starting construction.", "Working here.", "Working.", "Cover me, building here.", "Cover me!", "I'm working on it.", "Something need building?", "Work, work.")
+	///Chat lines for being unable to build something
+	var/list/unable_to_build_chat = list("Can we build it? No, it's FUBAR.", "Unable to build.", "I can't build that.", "Negative.", "Get someone else on it!", "Can't do it, sorry.")
+
+///Checks if we should be building anything
+/datum/ai_behavior/human/proc/engineer_process()
+	if(!length(engineering_list))
+		return
+	if(interact_target && (interact_target in engineering_list))
+		return
+	if(human_ai_state_flags & HUMAN_AI_FIRING)
+		return
+	if(current_action == MOVING_TO_SAFETY)
+		return
+	if(human_ai_state_flags & HUMAN_AI_BUSY_ACTION)
+		return
+	if(mob_parent.incapacitated() || mob_parent.lying_angle)
+		return
+
+	var/atom/engie_target
+	var/target_dist = 10 //lets just check screen range, more or less
+	for(var/atom/potential AS in engineering_list)
+		var/dist = get_dist(mob_parent, potential)
+		if(dist >= target_dist)
+			continue
+		if(istype(potential, /obj/effect/build_designator))
+			var/obj/effect/build_designator/hologram = potential
+			if(hologram.builder)
+				continue
+		engie_target = potential
+		target_dist = dist
+	if(!engie_target)
+		return
+
+	set_interact_target(engie_target)
+	return TRUE
+
+///Adds atom to list
+/datum/ai_behavior/human/proc/add_to_engineering_list(atom/new_target)
+	engineering_list |= new_target
+
+///Removes atom from list
+/datum/ai_behavior/human/proc/remove_from_engineering_list(atom/old_target)
+	engineering_list -= old_target
+
+///Our building ended, successfully or otherwise
+/datum/ai_behavior/human/proc/on_engineering_end(atom/old_target)
+	SIGNAL_HANDLER
+	human_ai_state_flags &= ~HUMAN_AI_BUILDING
+	if(QDELETED(old_target))
+		remove_from_engineering_list(old_target)
+	late_initialize()
+
+///Decides if we should do something when another mob goes crit
+/datum/ai_behavior/human/proc/on_holo_build_init(datum/source, obj/effect/build_designator/new_holo)
+	SIGNAL_HANDLER
+	if(new_holo.faction != mob_parent.faction)
+		return
+	if(new_holo.z != mob_parent.z)
+		return
+
+	add_to_engineering_list(new_holo)
+	if(get_dist(mob_parent, new_holo) > 5)
+		return
+	set_interact_target(new_holo)
+
+///Tries to build a holo designation
+/datum/ai_behavior/human/proc/try_build_holo(obj/effect/build_designator/hologram)
+	if(hologram.builder)
+		//Someone else is building it, but we put it at the end of the queue in case its not completed
+		remove_from_engineering_list(hologram)
+		add_to_engineering_list(hologram)
+		return
+	human_ai_state_flags |= HUMAN_AI_BUILDING
+	do_unset_target(hologram, FALSE)
+
+	var/obj/item/stack/building_stack
+	for(var/candidate in mob_inventory.engineering_list)
+		if(!istype(candidate, hologram.material_type))
+			continue
+		var/obj/item/stack/candi_stack = candidate
+		if(candi_stack.amount < hologram.recipe.req_amount)
+			continue
+		building_stack = candi_stack
+		break
+
+	if(!building_stack)
+		remove_from_engineering_list(hologram)
+		try_speak(pick(unable_to_build_chat))
+		return
+
+	try_speak(pick(building_chat))
+	hologram.attackby(building_stack, mob_parent)
+	on_engineering_end(hologram)
diff --git a/code/modules/ai/ai_behaviors/human_mobs/healing.dm b/code/modules/ai/ai_behaviors/human_mobs/healing.dm
index dc884498a6ff8..ce2ef9eb38ad5 100644
--- a/code/modules/ai/ai_behaviors/human_mobs/healing.dm
+++ b/code/modules/ai/ai_behaviors/human_mobs/healing.dm
@@ -1,4 +1,3 @@
-//todo: add a mobs to help list so we can record who needs help, even if we're too busy to help right now
 /datum/ai_behavior/human
 	///A list of mobs that might need healing
 	var/list/heal_list = list()
@@ -24,7 +23,7 @@
 		return
 	if(current_action == MOVING_TO_SAFETY)
 		return
-	if(human_ai_state_flags & HUMAN_AI_ANY_HEALING)
+	if(human_ai_state_flags & HUMAN_AI_BUSY_ACTION)
 		return
 	if(mob_parent.incapacitated() || mob_parent.lying_angle)
 		return
@@ -99,7 +98,7 @@
 		return
 	if(current_action == MOVING_TO_SAFETY)
 		return
-	if(human_ai_state_flags & HUMAN_AI_ANY_HEALING)
+	if(human_ai_state_flags & HUMAN_AI_BUSY_ACTION)
 		return
 	if(mob_parent.incapacitated() || mob_parent.lying_angle)
 		return
diff --git a/code/modules/ai/ai_behaviors/human_mobs/human_mob.dm b/code/modules/ai/ai_behaviors/human_mobs/human_mob.dm
index eeec9b1fd03b7..9ad9831f07436 100644
--- a/code/modules/ai/ai_behaviors/human_mobs/human_mob.dm
+++ b/code/modules/ai/ai_behaviors/human_mobs/human_mob.dm
@@ -59,6 +59,8 @@
 	if(mob_parent?.skills?.getRating(SKILL_MEDICAL) >= SKILL_MEDICAL_PRACTICED) //placeholder setter. Some jobs have high med but aren't medics...
 		medical_rating = AI_MED_MEDIC
 		RegisterSignals(SSdcs, list(COMSIG_GLOB_AI_NEED_HEAL, COMSIG_GLOB_MOB_CALL_MEDIC), PROC_REF(mob_need_heal))
+	if(mob_parent?.skills?.getRating(SKILL_CONSTRUCTION) >= SKILL_CONSTRUCTION_PLASTEEL) //placeholder setter. Some jobs have high construction but aren't engineers...
+		RegisterSignal(SSdcs, COMSIG_GLOB_HOLO_BUILD_INITIALIZED, PROC_REF(on_holo_build_init))
 	if(human_ai_behavior_flags & HUMAN_AI_AVOID_HAZARDS)
 		RegisterSignal(SSdcs, COMSIG_GLOB_AI_HAZARD_NOTIFIED, PROC_REF(add_hazard))
 		RegisterSignal(mob_parent, COMSIG_MOVABLE_Z_CHANGED, (PROC_REF(on_change_z)))
@@ -100,6 +102,9 @@
 	if((medical_rating >= AI_MED_MEDIC) && medic_process())
 		return
 
+	if((mob_parent?.skills?.getRating(SKILL_CONSTRUCTION) >= SKILL_CONSTRUCTION_PLASTEEL) && engineer_process())
+		return
+
 	if((human_parent.nutrition <= NUTRITION_HUNGRY) && length(mob_inventory.food_list) && (human_parent.nutrition + (37.5 * human_parent.reagents.get_reagent_amount(/datum/reagent/consumable/nutriment)) < NUTRITION_WELLFED))
 		for(var/obj/item/reagent_containers/food/food AS in mob_inventory.food_list)
 			if(!food.ai_should_use(human_parent))
@@ -127,7 +132,7 @@
 		weapon_process()
 
 /datum/ai_behavior/human/should_hold()
-	if(human_ai_state_flags & HUMAN_AI_ANY_HEALING)
+	if(human_ai_state_flags & HUMAN_AI_BUSY_ACTION)
 		return TRUE
 	if(HAS_TRAIT(mob_parent, TRAIT_IS_RELOADING))
 		return TRUE
@@ -138,7 +143,7 @@
 	return FALSE
 
 /datum/ai_behavior/human/scheduled_move()
-	if(human_ai_state_flags & HUMAN_AI_ANY_HEALING)
+	if(human_ai_state_flags & HUMAN_AI_BUSY_ACTION)
 		registered_for_move = FALSE
 		return
 	return ..()
@@ -153,11 +158,11 @@
 	. = ..()
 	if(!.)
 		return
-	if(human_ai_state_flags & HUMAN_AI_ANY_HEALING)
+	if(human_ai_state_flags & HUMAN_AI_BUSY_ACTION)
 		mob_parent.a_intent = INTENT_HELP
 
 /datum/ai_behavior/human/look_for_new_state(atom/next_target)
-	if(human_ai_state_flags & HUMAN_AI_ANY_HEALING)
+	if(human_ai_state_flags & HUMAN_AI_BUSY_ACTION)
 		return
 	if(!combat_target || ((get_dist(mob_parent, combat_target) > AI_COMBAT_TARGET_BLIND_DISTANCE) && !line_of_sight(mob_parent, combat_target, target_distance)))
 		if(combat_target)
@@ -183,7 +188,7 @@
 				change_action(ESCORTING_ATOM, escorted_atom)
 
 /datum/ai_behavior/human/state_process(atom/next_target)
-	if(human_ai_state_flags & HUMAN_AI_ANY_HEALING)
+	if(human_ai_state_flags & HUMAN_AI_BUSY_ACTION)
 		return
 	if((current_action == MOVING_TO_ATOM) && (atom_to_walk_to == combat_target))
 		return //we generally want to keep fighting
@@ -276,6 +281,18 @@
 /datum/ai_behavior/human/do_unset_target(atom/old_target, need_new_state = TRUE)
 	if(combat_target == old_target && (human_ai_state_flags & HUMAN_AI_FIRING))
 		stop_fire()
+
+	if(QDELETED(old_target)) //if they're deleted we need to ensure engineering and medical stuff is cleaned up properly
+		if(human_ai_state_flags & HUMAN_AI_HEALING)
+			on_heal_end(old_target)
+		else
+			remove_from_heal_list(old_target)
+		if(human_ai_state_flags & HUMAN_AI_BUILDING)
+			on_engineering_end(old_target)
+		else
+			remove_from_engineering_list(old_target)
+		return ..()
+
 	var/revive_target = FALSE
 	if((medical_rating >= AI_MED_MEDIC) && (old_target in heal_list))
 		var/mob/living/living_target = old_target
@@ -341,6 +358,9 @@
 			return
 		INVOKE_ASYNC(src, PROC_REF(try_heal_other), human)
 		return TRUE
+	if(istype(interactee, /obj/effect/build_designator))
+		INVOKE_ASYNC(src, PROC_REF(try_build_holo), interactee)
+		return TRUE
 	interactee.do_ai_interact(mob_parent)
 	return TRUE
 
@@ -365,8 +385,8 @@
 
 	if(attacker) //if there is an attacker, our main priority is to react to it
 		COOLDOWN_START(src, ai_heal_after_dam_cooldown, 4 SECONDS)
-		if((human_ai_state_flags & HUMAN_AI_ANY_HEALING)) //dont just stand there
-			human_ai_state_flags &= ~(HUMAN_AI_ANY_HEALING)
+		if((human_ai_state_flags & HUMAN_AI_BUSY_ACTION)) //dont just stand there
+			human_ai_state_flags &= ~(HUMAN_AI_BUSY_ACTION)
 			late_initialize()
 		if(((current_action == MOVING_TO_SAFETY) || !combat_target) && (attacker.faction != mob_parent.faction))
 			set_combat_target(attacker)
@@ -376,7 +396,7 @@
 
 	if(!(human_ai_behavior_flags & HUMAN_AI_SELF_HEAL))
 		return
-	if((human_ai_state_flags & HUMAN_AI_ANY_HEALING))
+	if((human_ai_state_flags & HUMAN_AI_BUSY_ACTION))
 		return
 
 	var/mob/living/living_mob = mob_parent
diff --git a/tgmc.dme b/tgmc.dme
index c1a69b330c1a8..1abeeacfc7ad5 100644
--- a/tgmc.dme
+++ b/tgmc.dme
@@ -1482,6 +1482,7 @@
 #include "code\modules\admin\view_variables\view_variables.dm"
 #include "code\modules\ai\ai_node.dm"
 #include "code\modules\ai\ai_behaviors\ai_behavior.dm"
+#include "code\modules\ai\ai_behaviors\human_mobs\engineering.dm"
 #include "code\modules\ai\ai_behaviors\human_mobs\grenades.dm"
 #include "code\modules\ai\ai_behaviors\human_mobs\hazard_avoidance.dm"
 #include "code\modules\ai\ai_behaviors\human_mobs\heal_procs.dm"


Reviews:
user: login: TiviPlus
body: otherwise lgtm
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-18T14:23:37Z
user: login: Lumipharon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T20:49:48Z
user: login: TiviPlus
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-18T21:24:47Z

Review comments:
diff_hunk: @@ -286,7 +303,7 @@
 	else
 		remove_from_heal_list(old_target)
 	if((human_ai_state_flags & HUMAN_AI_HEALING) && !revive_target)
-		on_heal_end(old_target)
+		on_heal_end(old_target) //this is problematic apparently
path: code/modules/ai/ai_behaviors/human_mobs/human_mob.dm
user: login: TiviPlus
body: ?
created_at: 2025-05-18T14:23:29Z
author_association: MEMBER
diff_hunk: @@ -286,7 +303,7 @@
 	else
 		remove_from_heal_list(old_target)
 	if((human_ai_state_flags & HUMAN_AI_HEALING) && !revive_target)
-		on_heal_end(old_target)
+		on_heal_end(old_target) //this is problematic apparently
path: code/modules/ai/ai_behaviors/human_mobs/human_mob.dm
user: login: Lumipharon
body: Forgot to clean it up, note to myself before I added that code above this.
created_at: 2025-05-18T20:49:48Z
author_association: CONTRIBUTOR

PR comments:
user: login: iforgotmeword
created_at: 2025-05-16T14:12:38Z
body: A deployable builder drone would be fun
Let's engs multi task setting up bases
author_association: NONE
user: login: Arturlang
created_at: 2025-05-16T22:46:53Z
body: Is it possible to make the bots hold materials before building?
author_association: CONTRIBUTOR
user: login: ghost
created_at: 2025-05-17T05:02:15Z
body: AFK mode when?
author_association: NONE