diff --git a/code/__DEFINES/bots.dm b/code/__DEFINES/bots.dm
index 9e77ad7779c..5e17320c6eb 100644
--- a/code/__DEFINES/bots.dm
+++ b/code/__DEFINES/bots.dm
@@ -37,6 +37,13 @@
#define HONK_BOT "Honkbot" // Honkbots
#define GRIEF_BOT "Grief" // Griefsky
+//AI notification defines
+#define NEW_BORG 1
+#define NEW_MODULE 2
+#define RENAME 3
+#define AI_SHELL 4
+#define DISCONNECT 5
+
//Sentience types
#define SENTIENCE_ORGANIC 1
#define SENTIENCE_ARTIFICIAL 2
diff --git a/code/__HELPERS/unsorted.dm b/code/__HELPERS/unsorted.dm
index e58fff9cd1a..5cb9a239262 100644
--- a/code/__HELPERS/unsorted.dm
+++ b/code/__HELPERS/unsorted.dm
@@ -280,7 +280,7 @@
var/select
var/list/borgs = list()
for(var/mob/living/silicon/robot/A in GLOB.player_list)
- if(A.stat == DEAD || A.connected_ai || A.scrambledcodes || isdrone(A))
+ if(A.stat == DEAD || A.connected_ai || A.scrambledcodes || isdrone(A) || A.shell)
continue
var/name = "[A.real_name] ([A.modtype] [A.braintype])"
borgs[name] = A
diff --git a/code/controllers/subsystem/SSshuttles.dm b/code/controllers/subsystem/SSshuttles.dm
index 9ef4aac0d98..bab1ecdc1b2 100644
--- a/code/controllers/subsystem/SSshuttles.dm
+++ b/code/controllers/subsystem/SSshuttles.dm
@@ -210,7 +210,9 @@ SUBSYSTEM_DEF(shuttle)
for(var/thing in GLOB.shuttle_caller_list)
if(is_ai(thing))
var/mob/living/silicon/ai/AI = thing
- if(AI.stat || !AI.client)
+ if(AI.deployed_shell && !AI.deployed_shell.client)
+ continue
+ if(AI.stat || (!AI.client && !AI.deployed_shell))
continue
else if(istype(thing, /obj/machinery/computer/communications))
var/obj/machinery/computer/communications/C = thing
diff --git a/code/datums/ai_laws_datums.dm b/code/datums/ai_laws_datums.dm
index 3cb983fa3ec..8338c92f5d6 100644
--- a/code/datums/ai_laws_datums.dm
+++ b/code/datums/ai_laws_datums.dm
@@ -94,7 +94,7 @@
/mob/living/silicon/proc/sync_zeroth(datum/ai_law/zeroth_law, datum/ai_law/zeroth_law_borg)
- if(!is_special_character(src) || !mind.is_original_mob(src))
+ if(!is_special_character(src) || (mind && !mind.is_original_mob(src)))
if(zeroth_law_borg)
laws.set_zeroth_law(zeroth_law_borg.law)
var/datum/atom_hud/data/human/malf_ai/H = GLOB.huds[DATA_HUD_MALF_AI]
diff --git a/code/datums/soullink.dm b/code/datums/soullink.dm
new file mode 100644
index 00000000000..b3c04c41dd7
--- /dev/null
+++ b/code/datums/soullink.dm
@@ -0,0 +1,129 @@
+/**
+ * Keeps track of a Mob->Mob (potentially Player->Player) connection.
+ * Can be used to trigger actions on one party when events happen to another
+ * (e.g. shared deaths).
+ * Can be used to form a linked list of mob-hopping.
+ * Does NOT transfer with minds.
+ */
+/datum/soullink
+ var/mob/living/soulowner
+ var/mob/living/soulsharer
+ /// Optional ID, for tagging and finding specific instances.
+ var/id
+
+/datum/soullink/Destroy()
+ if(soulowner)
+ LAZYREMOVE(soulowner.ownedSoullinks, src)
+ soulowner = null
+ if(soulsharer)
+ LAZYREMOVE(soulsharer.sharedSoullinks, src)
+ soulsharer = null
+ return ..()
+
+/datum/soullink/proc/removeSoulsharer(mob/living/sharer)
+ if(soulsharer == sharer)
+ soulsharer = null
+ LAZYREMOVE(sharer.sharedSoullinks, src)
+
+/**
+ * Used to assign variables, called primarily by `soullink()`.
+ * Override this to create more unique soul links (Eg: 1->Many relationships).
+ * Return `TRUE`/`FALSE` to return the soul link/null in `soullink()`.
+ */
+/datum/soullink/proc/parseArgs(mob/living/owner, mob/living/sharer)
+ if(!owner || !sharer)
+ return FALSE
+ soulowner = owner
+ soulsharer = sharer
+ LAZYADD(owner.ownedSoullinks, src)
+ LAZYADD(sharer.sharedSoullinks, src)
+ return TRUE
+
+/// Runs after `mob/living` `death()`. Override this for content.
+/datum/soullink/proc/ownerDies(gibbed, mob/living/owner)
+ return
+
+/// Runs after `mob/living` `death()`. Override this for content.
+/datum/soullink/proc/sharerDies(gibbed, mob/living/owner)
+ return
+
+/// Runs after `mob/living` `update_revive()`. Override this for content.
+/datum/soullink/proc/ownerRevives(mob/living/owner)
+ return
+
+/// Runs after `mob/living` `update_revive()`. Override this for content.
+/datum/soullink/proc/sharerRevives(mob/living/owner)
+ return
+
+//Quick-use helper
+/proc/soullink(typepath, ...)
+ var/datum/soullink/S = new typepath()
+ if(S.parseArgs(arglist(args.Copy(2, 0))))
+ return S
+
+// MARK: Multi Sharer
+/// Abstract soul link for use with 1 Owner -> Many Sharer setups.
+/datum/soullink/multisharer
+ var/list/soulsharers
+
+/datum/soullink/multisharer/parseArgs(mob/living/owner, list/sharers)
+ if(!owner || !LAZYLEN(sharers))
+ return FALSE
+ soulowner = owner
+ soulsharers = sharers
+ LAZYADD(owner.ownedSoullinks, src)
+ for(var/l in sharers)
+ var/mob/living/L = l
+ LAZYADD(L.sharedSoullinks, src)
+ return TRUE
+
+/datum/soullink/multisharer/removeSoulsharer(mob/living/sharer)
+ LAZYREMOVE(soulsharers, sharer)
+
+// MARK: Shared Fate
+/// When the soul owner dies, the soul sharer dies, and vice versa. This is intended for two players (or AI) and two mobs.
+/datum/soullink/sharedfate/ownerDies(gibbed, mob/living/owner)
+ if(soulsharer)
+ soulsharer.death(gibbed)
+
+/datum/soullink/sharedfate/sharerDies(gibbed, mob/living/sharer)
+ if(soulowner)
+ soulowner.death(gibbed)
+
+// MARK: Demon Bind
+/// When the soulowner dies, the soulsharer dies, but NOT vice versa. This is intended for two players(or AI) and two mobs.
+/datum/soullink/oneway/ownerDies(gibbed, mob/living/owner)
+ if(soulsharer)
+ soulsharer.dust(FALSE)
+
+/datum/soullink/oneway/devilfriend
+
+// MARK: Shared Body
+/// When the soulsharer dies, they're placed in the soulowner, who remains alive.
+/// If the soulowner dies, the soulsharer is killed and placed into the soulowner (who is still dying). This is intended for one player moving between many mobs.
+/datum/soullink/sharedbody/ownerDies(gibbed, mob/living/owner)
+ if(soulowner && soulsharer)
+ if(soulsharer.mind)
+ soulsharer.mind.transfer_to(soulowner)
+ soulsharer.death(gibbed)
+
+/datum/soullink/sharedbody/sharerDies(gibbed, mob/living/sharer)
+ if(soulowner && soulsharer && soulsharer.mind)
+ soulsharer.mind.transfer_to(soulowner)
+
+// MARK: Replacement Pool
+/// When the owner dies, one of the sharers is placed in the owner's body, fully healed.
+/// Sort of a "winner-stays-on" soullink. Gibbing ends it immediately.
+/datum/soullink/multisharer/replacementpool/ownerDies(gibbed, mob/living/owner)
+ if(LAZYLEN(soulsharers) && !gibbed) //let's not put them in some gibs
+ var/list/souls = shuffle(soulsharers.Copy())
+ for(var/l in souls)
+ var/mob/living/L = l
+ if(L.stat != DEAD && L.mind)
+ L.mind.transfer_to(soulowner)
+ soulowner.revive(TRUE, TRUE)
+ L.death(FALSE)
+
+//Lose your claim to the throne!
+/datum/soullink/multisharer/replacementpool/sharerDies(gibbed, mob/living/sharer)
+ removeSoulsharer(sharer)
diff --git a/code/datums/wires/robot_wires.dm b/code/datums/wires/robot_wires.dm
index 85242dff520..ddd5196df9c 100644
--- a/code/datums/wires/robot_wires.dm
+++ b/code/datums/wires/robot_wires.dm
@@ -25,6 +25,8 @@
to_chat(R, "LawSync protocol engaged.")
R.lawsync()
R.show_laws()
+ if(!R.deployed)
+ R.lawupdate = FALSE
else
if(!R.lawupdate && !R.emagged)
R.lawupdate = TRUE
@@ -32,6 +34,9 @@
if(WIRE_AI_CONTROL) //Cut the AI wire to reset AI control
if(!mend)
if(R.connected_ai)
+ R.notify_ai(DISCONNECT)
+ if(R.shell)
+ R.undeploy() //Forced disconnect of an AI should this body be a shell.
R.disconnect_from_ai()
if(WIRE_BORG_CAMERA)
diff --git a/code/game/data_huds.dm b/code/game/data_huds.dm
index c34a7150b6a..171bcbbcecc 100644
--- a/code/game/data_huds.dm
+++ b/code/game/data_huds.dm
@@ -608,3 +608,27 @@
if(!R.fields["comments"])
R.fields["comments"] = list()
R.fields["comments"] += list(comment_text)
+
+/// Borg-AI shell tracking.
+/// Shows tracking beacons on the mech.
+/mob/living/silicon/robot/proc/diag_hud_set_aishell()
+ var/image/holder = hud_list[DIAG_TRACK_HUD]
+ var/icon/I = icon(icon, icon_state, dir)
+ holder.pixel_y = I.Height() - world.icon_size
+ if(!shell) //Not an AI shell
+ holder.icon_state = null
+ else if(deployed) //AI shell in use by an AI
+ holder.icon_state = "hudtrackingai"
+ else //Empty AI shell
+ holder.icon_state = "hudtracking"
+
+/// AI side tracking of AI shell control.
+/// Shows tracking beacons on the mech.
+/mob/living/silicon/ai/proc/diag_hud_set_deployed()
+ var/image/holder = hud_list[DIAG_TRACK_HUD]
+ var/icon/I = icon(icon, icon_state, dir)
+ holder.pixel_y = I.Height() - world.icon_size
+ if(!deployed_shell)
+ holder.icon_state = null
+ else //AI is currently controlling a shell
+ holder.icon_state = "hudtrackingai"
diff --git a/code/game/gamemodes/malfunction/Malf_Modules.dm b/code/game/gamemodes/malfunction/Malf_Modules.dm
index 7daeb40c17f..6bd55b8e087 100644
--- a/code/game/gamemodes/malfunction/Malf_Modules.dm
+++ b/code/game/gamemodes/malfunction/Malf_Modules.dm
@@ -870,8 +870,9 @@
log_game("[key_name(usr)] purchased combat upgrades for all cyborgs.")
message_admins(SPAN_NOTICE("[key_name_admin(usr)] purchased combat upgrades for all cyborgs!"))
for(var/mob/living/silicon/robot/R in AI.connected_robots)
- R.module.malfhacked = TRUE
- R.module.rebuild_modules()
+ if(R.module)
+ R.module.malfhacked = TRUE
+ R.module.rebuild_modules()
to_chat(R, SPAN_NOTICE("New firmware downloaded. Combat upgrades are now online."))
/datum/ai_module/repair_cyborg
diff --git a/code/game/machinery/computer/robot_control.dm b/code/game/machinery/computer/robot_control.dm
index 26b18d089fd..6723b277826 100644
--- a/code/game/machinery/computer/robot_control.dm
+++ b/code/game/machinery/computer/robot_control.dm
@@ -254,9 +254,10 @@
log_game("[key_name(usr)] emagged [key_name(R)] using robotic console!")
message_admins(SPAN_NOTICE("[key_name_admin(usr)] emagged [key_name_admin(R)] using robotic console!"))
R.emagged = TRUE
- R.module.emag_act(usr)
- R.module.module_type = "Malf"
- R.update_module_icon()
- R.module.rebuild_modules()
+ if(R.module)
+ R.module.emag_act(usr)
+ R.module.module_type = "Malf"
+ R.update_module_icon()
+ R.module.rebuild_modules()
to_chat(R, SPAN_NOTICE("Failsafe protocols overridden. New tools available."))
. = TRUE
diff --git a/code/game/mecha/mecha.dm b/code/game/mecha/mecha.dm
index 1ed16555458..e87c6d27f99 100644
--- a/code/game/mecha/mecha.dm
+++ b/code/game/mecha/mecha.dm
@@ -1364,6 +1364,8 @@
to_chat(AI, SPAN_USERDANGER("Inactive core destroyed. Unable to return."))
AI.linked_core = null
return
+ AI.deploy_action.Grant(AI)
+ AI.redeploy_action.Grant(AI)
to_chat(AI, SPAN_NOTICE("Returning to core..."))
AI.controlled_mech = null
if(istype(AI.eyeobj))
diff --git a/code/game/objects/items/devices/radio/headset.dm b/code/game/objects/items/devices/radio/headset.dm
index 4ad1b91da16..828476c9a3a 100644
--- a/code/game/objects/items/devices/radio/headset.dm
+++ b/code/game/objects/items/devices/radio/headset.dm
@@ -515,4 +515,12 @@
qdel(keyslot1)
keyslot1 = new /obj/item/encryptionkey/syndicate
syndiekey = keyslot1
+ syndie = TRUE
+ recalculateChannels()
+
+/obj/item/radio/borg/proc/make_syndie()
+ qdel(keyslot)
+ keyslot = new /obj/item/encryptionkey/syndicate
+ syndiekey = keyslot
+ syndie = TRUE
recalculateChannels()
diff --git a/code/game/objects/items/devices/radio/radio_objects.dm b/code/game/objects/items/devices/radio/radio_objects.dm
index a28e2f26ca6..c095347072c 100644
--- a/code/game/objects/items/devices/radio/radio_objects.dm
+++ b/code/game/objects/items/devices/radio/radio_objects.dm
@@ -61,6 +61,8 @@ GLOBAL_LIST_EMPTY(deadsay_radio_systems)
var/obj/item/encryptionkey/syndicate/syndiekey = null
/// How many times this is disabled by EMPs
var/disable_timer = 0
+ /// Is the radio a syndie one?
+ var/syndie = FALSE
/// Areas in which this radio cannot send messages
var/static/list/blacklisted_areas = list(/area/adminconstruction, /area/tdome, /area/ruin/space/bubblegum_arena)
diff --git a/code/game/objects/items/robot/robot_parts.dm b/code/game/objects/items/robot/robot_parts.dm
index 94a1bed40dd..11dbf2aa353 100644
--- a/code/game/objects/items/robot/robot_parts.dm
+++ b/code/game/objects/items/robot/robot_parts.dm
@@ -343,6 +343,40 @@
to_chat(user, SPAN_WARNING("The MMI must go in after everything else!"))
return ITEM_INTERACT_COMPLETE
+ if(istype(used, /obj/item/borg/upgrade/ai))
+ var/obj/item/borg/upgrade/ai/M = used
+ if(check_completion())
+ if(!isturf(loc))
+ to_chat(user, SPAN_WARNING("You cannot install [M], the frame has to be standing on the ground to be perfectly precise!"))
+ return ITEM_INTERACT_COMPLETE
+ if(!user.drop_item())
+ to_chat(user, SPAN_WARNING("[M] is stuck to your hand!"))
+ return ITEM_INTERACT_COMPLETE
+ qdel(M)
+ var/mob/living/silicon/robot/O = new /mob/living/silicon/robot/shell(get_turf(src))
+
+ if(!aisync)
+ lawsync = FALSE
+ O.connected_ai = null
+ else
+ if(forced_ai)
+ O.connected_ai = forced_ai
+ O.notify_ai(AI_SHELL)
+ if(!lawsync)
+ O.lawupdate = FALSE
+ O.make_laws()
+
+ var/datum/robot_component/cell_component = O.components["power cell"]
+ cell_component.install(chest.cell)
+ chest.cell = null
+ O.locked = panel_locked
+ O.job = "Cyborg"
+ forceMove(O)
+ O.robot_suit = src
+ if(!locomotion)
+ O.lockcharge = TRUE
+ O.update_stamina_hud()
+ return ITEM_INTERACT_COMPLETE
if(is_pen(used))
to_chat(user, SPAN_WARNING("You need to use a multitool to name [src]!"))
return ITEM_INTERACT_COMPLETE
diff --git a/code/game/objects/items/robot/robot_upgrades.dm b/code/game/objects/items/robot/robot_upgrades.dm
index 2bb961c0635..e38d83f7c02 100644
--- a/code/game/objects/items/robot/robot_upgrades.dm
+++ b/code/game/objects/items/robot/robot_upgrades.dm
@@ -132,7 +132,7 @@
if(!R.allow_rename)
to_chat(R, SPAN_WARNING("Internal diagnostic error: incompatible upgrade module detected."))
return
- R.notify_ai(3, R.name, heldname)
+ R.notify_ai(RENAME, R.name, heldname)
R.name = heldname
R.custom_name = heldname
R.real_name = heldname
@@ -160,7 +160,7 @@
R.set_stat(CONSCIOUS)
GLOB.dead_mob_list -= R //please never forget this ever kthx
GLOB.alive_mob_list += R
- R.notify_ai(1)
+ R.notify_ai(NEW_BORG)
return TRUE
@@ -552,3 +552,17 @@
/obj/item/reagent_containers/spray/cleaner/safety/abductor,
/obj/item/lightreplacer/bluespace/abductor
)
+
+// MARK: B.O.R.I.S.
+/obj/item/borg/upgrade/ai
+ name = "B.O.R.I.S. module"
+ desc = "Bluespace Optimized Remote Intelligence Synchronization. An uplink device which takes the place of an MMI in cyborg endoskeletons, creating a robotic shell controlled by an AI."
+ icon_state = "bluespacearray"
+ origin_tech = "engineering=3;magnets=4;programming=5"
+ var/alien = FALSE // This is an incredibely scuffed way to do this, but it works.
+ var/syndiemmi = FALSE
+ var/brainmob = null
+
+/obj/item/borg/upgrade/ai/action(mob/living/silicon/robot/R)
+ to_chat(usr, SPAN_NOTICE("The B.O.R.I.S. module can only be installed into an endoskeleton.")) // Since the BORIS module is an upgrade that acts as an MMI.
+ return
diff --git a/code/modules/mob/living/death.dm b/code/modules/mob/living/death.dm
index 1a664e0ffe5..96986c2a74e 100644
--- a/code/modules/mob/living/death.dm
+++ b/code/modules/mob/living/death.dm
@@ -76,6 +76,13 @@
med_hud_set_health()
med_hud_set_status()
+ for(var/s in ownedSoullinks)
+ var/datum/soullink/S = s
+ S.ownerDies(gibbed, src)
+ for(var/s in sharedSoullinks)
+ var/datum/soullink/S = s
+ S.sharerDies(gibbed, src)
+
GLOB.alive_mob_list -= src
GLOB.dead_mob_list += src
if(mind)
diff --git a/code/modules/mob/living/living.dm b/code/modules/mob/living/living.dm
index 44e8fa2db46..b94000fedc3 100644
--- a/code/modules/mob/living/living.dm
+++ b/code/modules/mob/living/living.dm
@@ -59,6 +59,16 @@
if(mind?.current == src)
mind.unbind()
UnregisterSignal(src, COMSIG_ATOM_PREHIT)
+ for(var/s in ownedSoullinks)
+ var/datum/soullink/S = s
+ S.ownerDies(FALSE)
+ qdel(s) // If the owner is `destroy()`'d, the soul link is `destroy()`'d.
+ ownedSoullinks = null
+ for(var/s in sharedSoullinks)
+ var/datum/soullink/S = s
+ S.sharerDies(FALSE)
+ S.removeSoulsharer(src) // If a sharer is `destroy()`'d, they are simply removed.
+ sharedSoullinks = null
return ..()
/mob/living/ghostize(flags = GHOST_FLAGS_DEFAULT, ghost_name, ghost_color)
diff --git a/code/modules/mob/living/living_defines.dm b/code/modules/mob/living/living_defines.dm
index 9cfeb10fac6..33e0b4d24f8 100644
--- a/code/modules/mob/living/living_defines.dm
+++ b/code/modules/mob/living/living_defines.dm
@@ -142,3 +142,8 @@
/// How long does it take to harvest a mob?
var/butcher_time = 8 SECONDS
+
+ /// Soul links we are the owner of.
+ var/list/ownedSoullinks
+ /// Soul links we are the sharer of.
+ var/list/sharedSoullinks
diff --git a/code/modules/mob/living/silicon/ai/ai_examine.dm b/code/modules/mob/living/silicon/ai/ai_examine.dm
index 338bf0d7a8a..a925ab3b1a9 100644
--- a/code/modules/mob/living/silicon/ai/ai_examine.dm
+++ b/code/modules/mob/living/silicon/ai/ai_examine.dm
@@ -17,7 +17,9 @@
msg += "Its casing is melted and heat-warped!\n"
if(src.stat == UNCONSCIOUS)
msg += "It is non-responsive and displaying the text: \"RUNTIME: Sensory Overload, stack 26/3\".\n"
- if(!shunted && !client)
+ if(deployed_shell)
+ msg += "The wireless networking light is blinking.\n"
+ if(!shunted && !client && !deployed_shell) // An AI deployed to a shell will always lack a client, but also have a client just in another body.
msg += "[src]Core.exe has stopped responding! NTOS is searching for a solution to the problem...\n"
msg += ""
msg += ""
diff --git a/code/modules/mob/living/silicon/ai/ai_life.dm b/code/modules/mob/living/silicon/ai/ai_life.dm
index 5ebe7bd298c..1a9bb452b0d 100644
--- a/code/modules/mob/living/silicon/ai/ai_life.dm
+++ b/code/modules/mob/living/silicon/ai/ai_life.dm
@@ -62,6 +62,7 @@
else
if(lacks_power())
if(!aiRestorePowerRoutine)
+ disconnect_shell()
update_blind_effects()
aiRestorePowerRoutine = 1
update_sight()
@@ -149,10 +150,13 @@
health = 100
set_stat(CONSCIOUS)
else
- health = 100 - getOxyLoss() - getToxLoss() - getFireLoss() - getBruteLoss()
+ var/old_health = health
+ health = 100 - getOxyLoss() - getToxLoss() - getBruteLoss() - getFireLoss()
+ var/old_stat = stat
update_stat("updatehealth([reason])")
diag_hud_set_health()
-
+ if(old_health > health || old_stat != stat) // Only disconnect if we lose health or change stat.
+ disconnect_shell()
/mob/living/silicon/ai/proc/lacks_power()
var/turf/T = get_turf(src)
diff --git a/code/modules/mob/living/silicon/ai/ai_mob.dm b/code/modules/mob/living/silicon/ai/ai_mob.dm
index c4e7edc2531..f8f010db56f 100644
--- a/code/modules/mob/living/silicon/ai/ai_mob.dm
+++ b/code/modules/mob/living/silicon/ai/ai_mob.dm
@@ -147,8 +147,13 @@ GLOBAL_LIST_INIT(ai_verbs_default, list(
var/allow_teleporter = FALSE
var/obj/machinery/camera/portable/builtInCamera
-
- var/obj/structure/ai_core/deactivated/linked_core //For exosuit control
+ /// For exosuit control.
+ var/obj/structure/ai_core/deactivated/linked_core
+ // For shell control.
+ /// The shell an AI is deployed to.
+ var/mob/living/silicon/robot/deployed_shell = null
+ var/datum/action/innate/deploy_shell/deploy_action = new
+ var/datum/action/innate/deploy_last_shell/redeploy_action = new
/// If our AI doesn't want to be the arrivals announcer, this gets set to FALSE.
var/announce_arrivals = TRUE
@@ -224,6 +229,7 @@ GLOBAL_LIST_INIT(ai_verbs_default, list(
additional_law_channels["Holopad"] = ":h"
aiCamera = new/obj/item/camera/siliconcam/ai_camera(src)
+ deploy_action.Grant(src)
if(isturf(loc))
add_ai_verbs(src)
@@ -297,6 +303,7 @@ GLOBAL_LIST_INIT(ai_verbs_default, list(
GLOB.ai_list -= src
GLOB.shuttle_caller_list -= src
SSshuttle.autoEvac()
+ disconnect_shell()
if(malfhacking)
deltimer(malfhacking)
malfhacking = null
@@ -384,12 +391,87 @@ GLOBAL_LIST_INIT(ai_verbs_default, list(
var/dat_text = dat.Join("")
src << browse(dat_text, "window=aialerts&can_close=0")
+/mob/living/silicon/ai/proc/deploy_to_shell(mob/living/silicon/robot/target)
+ if(stat || lacks_power() || control_disabled)
+ to_chat(src, SPAN_DANGER("Wireless networking module is offline."))
+ return
+
+ var/list/possible = list()
+
+ for(var/borgie in GLOB.available_ai_shells)
+ var/mob/living/silicon/robot/R = borgie
+ if(R.shell && !R.deployed && (R.stat != DEAD) && (!R.connected_ai ||(R.connected_ai == src)))
+ possible += R
+
+ if(!LAZYLEN(possible))
+ to_chat(src, SPAN_NOTICE("No usable AI shell beacons detected."))
+ return
+
+ if(!target || !(target in possible)) // If the AI is looking for a new shell, or its pre-selected shell is no longer valid.
+ target = tgui_input_list(usr, "Which body to control?", "AI Shell Deployment", possible)
+
+ if(!target || target.stat == DEAD || target.deployed || !(!target.connected_ai ||(target.connected_ai == src)))
+ return
+
+ else if(mind)
+ soullink(/datum/soullink/sharedbody, src, target)
+ deployed_shell = target
+ target.deploy_init(src)
+ mind.transfer_to(target)
+ diag_hud_set_deployed()
+
+/datum/action/innate/deploy_shell
+ name = "Deploy to AI Shell"
+ desc = "Wirelessly control a specialized cyborg shell."
+ button_icon_state = "ai_shell"
+
+/datum/action/innate/deploy_shell/Trigger()
+ var/mob/living/silicon/ai/AI = owner
+ if(!AI)
+ return
+ AI.deploy_to_shell()
+
+/datum/action/innate/deploy_last_shell
+ name = "Reconnect to shell"
+ desc = "Reconnect to the most recently used AI shell."
+ button_icon_state = "ai_last_shell"
+ var/mob/living/silicon/robot/last_used_shell
+
+/datum/action/innate/deploy_last_shell/Trigger()
+ if(!owner)
+ return
+ if(last_used_shell)
+ var/mob/living/silicon/ai/AI = owner
+ AI.deploy_to_shell(last_used_shell)
+ else
+ Remove(owner) // If the last shell is blown, destroy it.
+
+/// Disconnect the AI from its shell.
+/mob/living/silicon/ai/proc/disconnect_shell()
+ if(deployed_shell) // Forcibly call back AI in event of things such as damage, EMP or power loss.
+ to_chat(src, SPAN_DANGER("Your remote connection has been reset!"))
+ deployed_shell.undeploy()
+ diag_hud_set_deployed()
+
+/mob/living/silicon/ai/proc/spawn_shell()
+ var/obj/shell_landmark
+ for(var/obj/effect/landmark/shell_loc in GLOB.landmarks_list)
+ if(shell_loc.name == "AI Shell")
+ shell_landmark = shell_loc
+
+ var/mob/living/silicon/robot/S = new /mob/living/silicon/robot(shell_landmark.loc)
+ var/obj/item/borg/upgrade/ai/board = new /obj/item/borg/upgrade/ai
+ S.make_shell(board)
+
/mob/living/silicon/ai/proc/show_borg_info(list/status_tab_data)
status_tab_data[++status_tab_data.len] = list("Connected cyborg count:", "[length(connected_robots)]")
for(var/mob/living/silicon/robot/R in connected_robots)
var/robot_status = "Nominal"
if(R.stat || !R.client)
robot_status = "OFFLINE"
+ // This needs to be below the OFFLINE check since shells will always lack a client form the AI's view.
+ else if(R.shell)
+ robot_status = "AI SHELL"
else if(!R.cell || R.cell.charge <= 0)
robot_status = "DEPOWERED"
// Name, Health, Battery, Module, Area, and Status! Everything an AI wants to know about its borgies!
@@ -808,6 +890,7 @@ GLOBAL_LIST_INIT(ai_verbs_default, list(
..()
Stun((12 SECONDS) / severity)
view_core()
+ disconnect_shell()
/mob/living/silicon/ai/ex_act(severity)
..()
@@ -904,7 +987,9 @@ GLOBAL_LIST_INIT(ai_verbs_default, list(
to_chat(src, SPAN_WARNING("This exosuit has a pilot and cannot be controlled."))
return
if(M)
- M.transfer_ai(AI_MECH_HACK, src, usr) //Called om the mech itself.
+ deploy_action.Remove(src)
+ redeploy_action.Remove(src)
+ M.transfer_ai(AI_MECH_HACK, src, usr) // Called on the mech itself.
else if(href_list["open"])
var/mob/target = locate(href_list["open"]) in GLOB.mob_list
@@ -1459,6 +1544,7 @@ GLOBAL_LIST_INIT(ai_verbs_default, list(
if(!..())
return
if(interaction == AI_TRANS_TO_CARD)//The only possible interaction. Upload AI mob to a card.
+ disconnect_shell() // If the AI is controlling a borg, force the player back to core.
if(!mind)
to_chat(user, SPAN_WARNING("No intelligence patterns detected."))//No more magical carding of empty cores, AI RETURN TO BODY!!!11
return
diff --git a/code/modules/mob/living/silicon/robot/robot_damage.dm b/code/modules/mob/living/silicon/robot/robot_damage.dm
index 55ccaef9280..ff9584f1db4 100644
--- a/code/modules/mob/living/silicon/robot/robot_damage.dm
+++ b/code/modules/mob/living/silicon/robot/robot_damage.dm
@@ -144,6 +144,7 @@
if(status_flags & GODMODE)
return
+ var/old_health = health
brute = max((brute - damage_protection) * brute_mod, 0)
burn = max((burn - damage_protection) * burn_mod, 0)
@@ -153,6 +154,8 @@
if(A)
A.take_damage(brute, burn, sharp)
updatehealth()
+ if((old_health > health) && shell && deployed && mainframe) // Only disconnect if we lose health.
+ mainframe.disconnect_shell()
return
while(LAZYLEN(parts) && (brute > 0 || burn > 0))
@@ -168,6 +171,8 @@
parts -= picked
updatehealth()
+ if((old_health > health) && shell && deployed && mainframe) // Only disconnect if we lose health.
+ mainframe.disconnect_shell()
/*
Begins the stamcrit reboot process for borgs. Stuns them, and warns people if the borg has no power source.
diff --git a/code/modules/mob/living/silicon/robot/robot_defense.dm b/code/modules/mob/living/silicon/robot/robot_defense.dm
index 5f8a811aada..7ad443f3a7b 100644
--- a/code/modules/mob/living/silicon/robot/robot_defense.dm
+++ b/code/modules/mob/living/silicon/robot/robot_defense.dm
@@ -34,9 +34,15 @@
return
/mob/living/silicon/robot/attack_ai(mob/user)
- if(user.a_intent == INTENT_HELP && is_ai(user))
+ if(user.a_intent == INTENT_HELP && is_ai(user) && !shell) // We check if is not a shell so we don't pet and deploy at the same time.
to_chat(src, SPAN_ROBOTEMOTE("[user] gives you a digital headpat."))
to_chat(user, SPAN_ROBOTEMOTE("You give [src] a digital headpat."))
+ else if(user.a_intent == INTENT_HELP && is_ai(user) && shell)
+ var/mob/living/silicon/ai/AI = user
+ if(AI.controlled_mech) // If the AI is in a mech it can’t control a shell.
+ to_chat(AI, SPAN_WARNING("Disconnect from your mech before piloting a shell."))
+ return
+ AI.deploy_to_shell(src)
/mob/living/silicon/robot/attack_hand(mob/living/carbon/human/user)
add_fingerprint(user)
diff --git a/code/modules/mob/living/silicon/robot/robot_examine.dm b/code/modules/mob/living/silicon/robot/robot_examine.dm
index 30786e3f66e..6f1def50dd0 100644
--- a/code/modules/mob/living/silicon/robot/robot_examine.dm
+++ b/code/modules/mob/living/silicon/robot/robot_examine.dm
@@ -34,17 +34,20 @@
if(cell && cell.charge <= 0)
msg += "[SPAN_WARNING("[p_their(TRUE)] battery indicator is blinking red!")]\n"
- switch(stat)
- if(CONSCIOUS)
- if(!client)
- msg += "[p_they(TRUE)] appear[p_s()] to be in stand-by mode.\n" //afk
- if(UNCONSCIOUS)
- msg += "[SPAN_WARNING("[p_they(TRUE)] [p_do()]n't seem to be responding.")]\n"
- if(DEAD)
- if(!suiciding)
- msg += "[SPAN_DEADSAY("It looks like [p_their()] internal subsystems are beyond repair and require replacing.")]\n"
- else
- msg += "[SPAN_WARNING("It looks like [p_their()] system is corrupted beyond repair. There is no hope of recovery.")]\n"
+ if(shell)
+ msg += "It appears to be an [deployed ? "active" : "empty"] AI shell.\n"
+ else
+ switch(stat)
+ if(CONSCIOUS)
+ if(!client)
+ msg += "[p_they(TRUE)] appear[p_s()] to be in stand-by mode.\n" //afk
+ if(UNCONSCIOUS)
+ msg += "[SPAN_WARNING("[p_they(TRUE)] [p_do()]n't seem to be responding.")]\n"
+ if(DEAD)
+ if(!suiciding)
+ msg += "[SPAN_DEADSAY("It looks like [p_their()] internal subsystems are beyond repair and require replacing.")]\n"
+ else
+ msg += "[SPAN_WARNING("It looks like [p_their()] system is corrupted beyond repair. There is no hope of recovery.")]\n"
msg += ""
if(print_flavor_text())
diff --git a/code/modules/mob/living/silicon/robot/robot_laws.dm b/code/modules/mob/living/silicon/robot/robot_laws.dm
index 06a9f6bd001..14d3b2c798f 100644
--- a/code/modules/mob/living/silicon/robot/robot_laws.dm
+++ b/code/modules/mob/living/silicon/robot/robot_laws.dm
@@ -30,7 +30,9 @@
to_chat(who, "Obey these laws:")
laws.show_laws(who)
// TODO: Update to new antagonist system.
- if(mind && (mind.special_role == SPECIAL_ROLE_TRAITOR && mind.is_original_mob(src)) && connected_ai)
+ if(shell) // AI shell.
+ to_chat(who, SPAN_USERDANGER("Remember, you are an AI remotely controlling your shell, other AIs can be ignored."))
+ else if(mind && (mind.special_role == SPECIAL_ROLE_TRAITOR && mind.is_original_mob(src)) && connected_ai)
to_chat(who, "Remember, [connected_ai.name] is technically your master, but your objective comes first.")
else if(connected_ai)
to_chat(who, "Remember, [connected_ai.name] is your master, other AIs can be ignored.")
diff --git a/code/modules/mob/living/silicon/robot/robot_login.dm b/code/modules/mob/living/silicon/robot/robot_login.dm
index d5cf2575911..057ff0de6ed 100644
--- a/code/modules/mob/living/silicon/robot/robot_login.dm
+++ b/code/modules/mob/living/silicon/robot/robot_login.dm
@@ -4,7 +4,7 @@
..()
show_laws(0)
- if(connected_ai)
+ if(connected_ai && connected_ai.mind)
if(connected_ai.mind.special_role == SPECIAL_ROLE_TRAITOR && connected_ai.malf_picker)
make_malf_robot()
regenerate_icons()
diff --git a/code/modules/mob/living/silicon/robot/robot_mob.dm b/code/modules/mob/living/silicon/robot/robot_mob.dm
index 8000834aaaf..fee84578220 100644
--- a/code/modules/mob/living/silicon/robot/robot_mob.dm
+++ b/code/modules/mob/living/silicon/robot/robot_mob.dm
@@ -1,6 +1,7 @@
GLOBAL_LIST_INIT(robot_verbs_default, list(
/mob/living/silicon/robot/proc/sensor_mode,
))
+GLOBAL_LIST_EMPTY(available_ai_shells)
/mob/living/silicon/robot
name = "Cyborg"
@@ -19,6 +20,13 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
var/custom_name = ""
var/custom_sprite = FALSE // Due to all the sprites involved, a var for our custom borgs may be best.
+ /// Is this borg a shell?
+ var/shell = FALSE
+ /// Is this shell currently deployed?
+ var/deployed = FALSE
+ /// The AI deployed to a shell.
+ var/mob/living/silicon/ai/mainframe = null
+ var/datum/action/innate/undeployment/undeployment_action = new
// HUD stuff.
var/atom/movable/screen/hands = null
var/list/inventory_screens = list()
@@ -142,7 +150,7 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
/// When the camera moved signal was sent last. Avoid overdoing it.
var/last_camera_update
- hud_possible = list(SPECIALROLE_HUD, DIAG_STAT_HUD, DIAG_HUD, DIAG_BATT_HUD)
+ hud_possible = list(SPECIALROLE_HUD, DIAG_STAT_HUD, DIAG_HUD, DIAG_BATT_HUD, DIAG_TRACK_HUD)
var/default_cell_type = /obj/item/stock_parts/cell/high
/// Does the robot have ion thrusters installed?
@@ -193,8 +201,12 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
if(wires.is_cut(WIRE_BORG_CAMERA)) // 5 = BORG CAMERA
camera.turn_off(src, FALSE)
- if(mmi == null)
- mmi = new /obj/item/mmi/robotic_brain(src) //Give the borg an MMI if he spawns without for some reason. (probably not the correct way to spawn a robotic brain, but it works)
+ if(shell)
+ var/obj/item/borg/upgrade/ai/board = new(src)
+ make_shell(board)
+
+ else if(mmi == null)
+ mmi = new /obj/item/mmi/robotic_brain(src) // Give the borg an MMI if they spawn without for some reason (probably not the correct way to spawn a robotic brain, but it works).
mmi.icon_state = "boris"
initialize_components()
@@ -251,11 +263,13 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
playsound(loc, 'sound/voice/liveagain.ogg', 75, 1)
/mob/living/silicon/robot/rename_character(oldname, newname)
+ if(shell)
+ return
if(!..(oldname, newname))
return FALSE
if(oldname != real_name)
- notify_ai(3, oldname, newname)
+ notify_ai(RENAME, oldname, newname)
custom_name = (newname != get_default_name()) ? newname : null
setup_PDA()
@@ -347,6 +361,9 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
/mob/living/silicon/robot/Destroy()
remove_robot_mindslave() // You cannot be connected to the malf AI if you are a pile of debris.
SStgui.close_uis(wires)
+ if(shell)
+ undeploy()
+ revert_shell()
if(mmi && mind)//Safety for when a cyborg gets dust()ed. Or there is no MMI inside.
var/turf/T = get_turf(loc)//To hopefully prevent run time errors.
if(T)
@@ -687,27 +704,33 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
switch(selected_module)
if("Engineering")
module = new /obj/item/robot_module/engineering(src)
- module.channels = list("Engineering" = 1)
+ // AI shells have the same channels as the AI itself so we skip it.
+ if(!shell)
+ module.channels = list("Engineering" = 1)
if(camera && ("Robots" in camera.network))
camera.network += "Engineering"
if("Janitor")
module = new /obj/item/robot_module/janitor(src)
- module.channels = list("Service" = 1)
+ if(!shell)
+ module.channels = list("Service" = 1)
if("Medical")
module = new /obj/item/robot_module/medical(src)
- module.channels = list("Medical" = 1)
+ if(!shell)
+ module.channels = list("Medical" = 1)
if(camera && ("Robots" in camera.network))
camera.network += "Medical"
status_flags &= ~CANPUSH
has_advanced_reagent_vision = TRUE
if("Mining")
module = new /obj/item/robot_module/miner(src)
- module.channels = list("Supply" = 1)
+ if(!shell)
+ module.channels = list("Supply" = 1)
if(camera && ("Robots" in camera.network))
camera.network += "Mining Outpost"
if("Service")
module = new /obj/item/robot_module/butler(src)
- module.channels = list("Service" = 1)
+ if(!shell)
+ module.channels = list("Service" = 1)
has_advanced_reagent_vision = TRUE
if(selected_sprite == "Bro")
module.module_type = "Brobot"
@@ -724,19 +747,22 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
return FALSE
modtype = selected_module
designation = selected_module
+ if(shell) // Update the shell name right after choosing a module.
+ real_name = "[real_name] -[designation]"
+ name = real_name
module.add_languages(src)
module.add_armor(src)
module.add_subsystems_and_actions(src)
if(emagged)
module.emag_act(src)
- if(!static_radio_channels)
+ if(!static_radio_channels && !shell) // Shells have the same channels as the AI and we dont want to reset them.
radio.config(module.channels)
rename_character(real_name, get_default_name())
initialize_sprites(selected_sprite, module_sprites)
if(client.stat_tab == "Status")
SSstatpanels.set_status_tab(client)
SSblackbox.record_feedback("tally", "cyborg_modtype", 1, "[lowertext(selected_module)]")
- notify_ai(2)
+ notify_ai(NEW_MODULE)
/mob/living/silicon/robot/proc/initialize_sprites(selected_sprite, list/module_sprites)
var/image/sprite_image = module_sprites[selected_sprite]
@@ -750,10 +776,12 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
for(var/obj/item/borg/upgrade/U in contents)
if(istype(U, /obj/item/borg/upgrade/reset)) // The reset module is supposed to be consumed on use, this stops it from dropping on the floor if used
QDEL_NULL(U)
+ if(istype(U, /obj/item/borg/upgrade/ai)) // So you can change the shell module but not drop the BORIS module.
+ continue
U.forceMove(get_turf(src))
/mob/living/silicon/robot/proc/reset_module()
- notify_ai(2)
+ notify_ai(NEW_MODULE)
client?.screen -= hud_used.module_store_icon
uneq_all()
SStgui.close_user_uis(src)
@@ -1072,8 +1100,7 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
module?.update_cells()
diag_hud_set_borgcell()
return ITEM_INTERACT_COMPLETE
-
- if(istype(used, /obj/item/encryptionkey) && opened)
+ if(istype(used, /obj/item/encryptionkey/) && opened)
if(radio)
to_chat(user, SPAN_NOTICE("You install [used] into [src]'s radio."))
radio.item_interaction(user, used)
@@ -1167,8 +1194,11 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
update_icons()
I.play_tool_sound(user, I.tool_volume)
else //radio check
+ if(shell) // Prevents AI shell key theft.
+ to_chat(user, SPAN_NOTICE("The shell appears to not have an encryption key."))
+ return
if(radio)
- radio.screwdriver_act(user, I)//Push it to the radio to let it handle everything
+ radio.screwdriver_act(user, I) // Push it to the radio to let it handle everything.
else
to_chat(user, "Unable to locate a radio.")
update_icons()
@@ -1275,10 +1305,20 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
to_chat(user, SPAN_NOTICE("You emag the cover lock."))
locked = FALSE
+ // A warning to Traitors who may not know that emagging AI shells does not slave them.
+ if(shell)
+ to_chat(user, SPAN_BOLDWARNING("[src] seems to be controlled remotely! Emagging the interface may not work as expected."))
log_game("[user]([user.key]) emagged [src]'s cover.")
return TRUE
if(opened)
+ if(shell) // AI shells cannot be emagged, so we try to make it look like a standard reset. Smart players may see through this, however.
+ to_chat(user, SPAN_BOLDWARNING("[src] is remotely controlled! Your emag attempts to disable AI control!"))
+ log_game("[key_name(user)] attempted to emag an AI shell belonging to [key_name(src) ? key_name(src) : connected_ai]. The shell has been reset as a result.")
+ undeploy()
+ reset_module()
+ revert_shell()
+ return
if(emagged)
to_chat(user, SPAN_WARNING("The emag sparks, and flashes red. [src] has already been emagged!"))
return
@@ -1444,6 +1484,8 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
/mob/living/silicon/robot/proc/deconstruct()
var/turf/T = get_turf(src)
+ if(shell)
+ undeploy()
if(robot_suit)
robot_suit.forceMove(T)
robot_suit.l_leg.forceMove(T)
@@ -1555,16 +1597,35 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
for(var/datum/action/innate/robot_override_lock/override in actions)
override.Remove(src)
+/**
+ * Notifies the AI of a certain event related to borgs and shells.
+ *
+ * Arguments:
+ * * notifytype - The type of notification to send.
+ * * oldname - The old name of the cyborg.
+ * * newname - The new name of the cyborg.
+ *
+ * Notify Types:
+ * - NEW_BORG: A new cyborg has connected.
+ * - NEW_MODULE: A cyborg has loaded a new module.
+ * - RENAME: A cyborg has been renamed.(This one needs the old and new name arguments.)
+ * - AI_SHELL: A new AI shell has been detected.
+ * - DISCONNECT: A cyborg has disconnected.
+ */
/mob/living/silicon/robot/proc/notify_ai(notifytype, oldname, newname)
if(!connected_ai)
return
switch(notifytype)
- if(1) //New Cyborg
+ if(NEW_BORG) // New Cyborg.
to_chat(connected_ai, "
[SPAN_NOTICE("NOTICE - New cyborg connection detected: [name]")]
")
- if(2) //New Module
+ if(NEW_MODULE) // New Module.
to_chat(connected_ai, "
[SPAN_NOTICE("NOTICE - Cyborg module change detected: [name] has loaded the [designation] module.")]
")
- if(3) //New Name
+ if(RENAME) // New Name.
to_chat(connected_ai, "
[SPAN_NOTICE("NOTICE - Cyborg reclassification detected: [oldname] is now designated as [newname].")]
")
+ if(AI_SHELL) // New Shell.
+ to_chat(connected_ai, "
[SPAN_NOTICE("NOTICE - New cyborg shell detected: [name]")]
")
+ if(DISCONNECT) // Disconnect.
+ to_chat(connected_ai, "
[SPAN_NOTICE("NOTICE - Remote telemetry lost with [name].")]
")
/mob/living/silicon/robot/proc/disconnect_from_ai()
if(connected_ai)
@@ -1578,7 +1639,9 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
if(AI && AI != connected_ai)
disconnect_from_ai()
set_connected_ai(AI)
- notify_ai(1)
+ // Shells get notifications already, so we don't want to duplicate the message for them.
+ if(!shell)
+ notify_ai(NEW_BORG)
if(AI.mind.special_role == ROLE_TRAITOR && AI.malf_picker)
make_malf_robot(AI)
if(module)
@@ -1721,7 +1784,9 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
if(emp_protection)
return
..()
- adjustStaminaLoss((30 / severity)) //They also get flashed for an additional 30
+ if(shell)
+ undeploy()
+ adjustStaminaLoss((30 / severity)) // They also get flashed for an additional 30.
switch(severity)
if(EMP_HEAVY)
disable_random_component(2, 20 SECONDS)
@@ -2025,3 +2090,93 @@ GLOBAL_LIST_INIT(robot_verbs_default, list(
if(curse_time == -1)
QDEL_NULL(mmi)
return ..()
+
+/mob/living/silicon/robot/proc/make_shell(obj/item/borg/upgrade/ai/board)
+ if(isnull(board))
+ stack_trace("make_shell was called without a board argument! This is never supposed to happen!")
+ return FALSE
+ shell = TRUE
+ mmi = board // This is to drop the BORIS module when we deconstruct a shell.
+ braintype = "AI Shell"
+ name = "AI Shell [rand(100,999)]"
+ real_name = name
+ GLOB.available_ai_shells |= src
+ if(camera)
+ camera.c_tag = real_name // Update the camera name too.
+
+/// Reverts a shell back to a unformatted cyborg also drops the BORIS module.
+/mob/living/silicon/robot/proc/revert_shell()
+ if(!shell)
+ return
+ notify_ai(DISCONNECT)
+ shell = FALSE
+ GLOB.available_ai_shells -= src
+ name = "Unformatted Cyborg [rand(100,999)]"
+ real_name = name
+ for(var/obj/item/borg/upgrade/ai/U in src.contents)
+ if(U)
+ U.forceMove(src.loc)
+ if(camera)
+ camera.c_tag = real_name
+ diag_hud_set_aishell()
+
+/mob/living/silicon/robot/proc/deploy_init(mob/living/silicon/ai/AI)
+ real_name = "[AI.real_name] shell [rand(100, 999)] [designation ? "-[designation]" : ""]" // Randomizing the name so it shows up seperately in the shells list.
+ name = real_name
+ setup_PDA()
+ if(camera)
+ camera.c_tag = real_name // Update the camera name too.
+ mainframe = AI
+ deployed = TRUE
+ connected_ai = mainframe
+ mainframe.connected_robots |= src
+ lawupdate = TRUE
+ lawsync()
+ if(radio && AI.aiRadio) // AI keeps all channels, including Syndie if it is a Traitor.
+ if(AI.aiRadio.syndie)
+ radio.make_syndie()
+ radio.channels = AI.aiRadio.channels
+ for(var/chan in radio.channels)
+ radio.secure_radio_connections[chan] = SSradio.add_object(radio, SSradio.radiochannels[chan], RADIO_CHAT)
+
+ diag_hud_set_aishell()
+ undeployment_action.Grant(src)
+
+/datum/action/innate/undeployment
+ name = "Disconnect from shell"
+ desc = "Stop controlling your shell and resume normal core operations."
+ button_icon_state = "ai_core"
+
+/datum/action/innate/undeployment/Trigger()
+ if(!..())
+ return FALSE
+ var/mob/living/silicon/robot/R = owner
+
+ R.undeploy()
+ return TRUE
+
+/// Undeploys the AI from its shell.
+/mob/living/silicon/robot/proc/undeploy()
+ if(!deployed || !mind || !mainframe)
+ return
+ mainframe.redeploy_action.Grant(mainframe)
+ mainframe.redeploy_action.last_used_shell = src
+ mind.transfer_to(mainframe)
+ deployed = FALSE
+ mainframe.deployed_shell = null
+ undeployment_action.Remove(src)
+ if(radio) // Return radio to normal.
+ radio.recalculateChannels()
+ if(camera)
+ camera.c_tag = real_name // Update the camera name too.
+ diag_hud_set_aishell()
+ mainframe.diag_hud_set_deployed()
+ if(mainframe.laws)
+ mainframe.laws.show_laws(mainframe) // Always remind the AI when switching.
+ if(mainframe.eyeobj)// Makes it so that when an AI undeploys its view isn’t moved to its core.
+ mainframe.eyeobj.set_loc(loc)
+ mainframe = null
+
+/mob/living/silicon/robot/shell
+ shell = TRUE
+ allow_rename = FALSE // This is to prevent someone renaming the shell and causing confusion with it.
diff --git a/code/modules/mob/living/silicon/robot/robot_module_actions.dm b/code/modules/mob/living/silicon/robot/robot_module_actions.dm
index e3c61d3f87c..229a7075d4d 100644
--- a/code/modules/mob/living/silicon/robot/robot_module_actions.dm
+++ b/code/modules/mob/living/silicon/robot/robot_module_actions.dm
@@ -78,7 +78,7 @@
/datum/action/innate/robot_sight/engineering_scanner/process()
var/mob/living/silicon/robot/user = owner
- if(!user.client)
+ if(!user || !user.client)
return
switch(mode)
if(MODE_TRAY)
diff --git a/code/modules/mob/living/silicon/robot/robot_update_status.dm b/code/modules/mob/living/silicon/robot/robot_update_status.dm
index ee73611f6cc..0c189bc78e6 100644
--- a/code/modules/mob/living/silicon/robot/robot_update_status.dm
+++ b/code/modules/mob/living/silicon/robot/robot_update_status.dm
@@ -38,6 +38,7 @@
diag_hud_set_status()
diag_hud_set_health()
update_health_hud()
+ diag_hud_set_aishell()
/mob/living/silicon/robot/KnockOut(updating = TRUE)
. = ..()
diff --git a/code/modules/mob/living/silicon/silicon_mob.dm b/code/modules/mob/living/silicon/silicon_mob.dm
index 0b4c915014b..1d6dc82644b 100644
--- a/code/modules/mob/living/silicon/silicon_mob.dm
+++ b/code/modules/mob/living/silicon/silicon_mob.dm
@@ -65,7 +65,7 @@
//var/sensor_mode = 0 //Determines the current HUD.
- hud_possible = list(SPECIALROLE_HUD, DIAG_STAT_HUD, DIAG_HUD)
+ hud_possible = list(SPECIALROLE_HUD, DIAG_STAT_HUD, DIAG_HUD, DIAG_TRACK_HUD)
var/med_hud = DATA_HUD_MEDICAL_ADVANCED //Determines the med hud to use
diff --git a/code/modules/mob/living/stat_states.dm b/code/modules/mob/living/stat_states.dm
index 1f71e4a9d39..f0e685bf9ba 100644
--- a/code/modules/mob/living/stat_states.dm
+++ b/code/modules/mob/living/stat_states.dm
@@ -84,6 +84,13 @@
var/datum/spell/spell = S
spell.build_all_button_icons()
+ for(var/s in ownedSoullinks)
+ var/datum/soullink/S = s
+ S.ownerRevives(src)
+ for(var/s in sharedSoullinks)
+ var/datum/soullink/S = s
+ S.sharerRevives(src)
+
return TRUE
/mob/living/proc/check_death_method()
diff --git a/code/modules/projectiles/projectile/magic_projectiles.dm b/code/modules/projectiles/projectile/magic_projectiles.dm
index 2037e1c1f7d..9d323433970 100644
--- a/code/modules/projectiles/projectile/magic_projectiles.dm
+++ b/code/modules/projectiles/projectile/magic_projectiles.dm
@@ -239,7 +239,7 @@ GLOBAL_LIST_INIT(wabbajack_docile_animals, list(
if(isrobot(M))
var/mob/living/silicon/robot/Robot = M
QDEL_NULL(Robot.mmi)
- Robot.notify_ai(1)
+ Robot.notify_ai(NEW_BORG)
else
if(ishuman(M))
var/mob/living/carbon/human/H = M
diff --git a/code/modules/research/designs/misc_designs.dm b/code/modules/research/designs/misc_designs.dm
index df741781b63..a08083a7148 100644
--- a/code/modules/research/designs/misc_designs.dm
+++ b/code/modules/research/designs/misc_designs.dm
@@ -31,6 +31,17 @@
build_path = /obj/item/aicard
category = list("Miscellaneous")
+/datum/design/boris_ai_controller
+ name = "B.O.R.I.S."
+ desc = "Bluespace Optimized Remote Intelligence Synchronization. An uplink device which takes the place of an MMI in cyborg endoskeletons, creating a robotic shell controlled by an AI."
+ id = "borg_ai_control"
+ req_tech = list("programming" = 5, "magnets" = 4, "engineering" = 3)
+ build_type = MECHFAB | PROTOLATHE
+ materials = list(MAT_METAL = 1700, MAT_GLASS = 1350, MAT_GOLD = 500) // Same as robobrain.
+ construction_time = 75
+ build_path = /obj/item/borg/upgrade/ai
+ category = list("Miscellaneous", "Misc")
+
/datum/design/paicard
name = "Personal Artificial Intelligence Card"
desc = "Allows for the construction of a pAI Card."
diff --git a/icons/mob/actions/actions.dmi b/icons/mob/actions/actions.dmi
index 9fd56c2c428..86b87f3a286 100644
Binary files a/icons/mob/actions/actions.dmi and b/icons/mob/actions/actions.dmi differ
diff --git a/paradise.dme b/paradise.dme
index 656b6ea2ca8..78c18b9e11c 100644
--- a/paradise.dme
+++ b/paradise.dme
@@ -478,6 +478,7 @@
#include "code\datums\revision.dm"
#include "code\datums\ruins.dm"
#include "code\datums\shuttles.dm"
+#include "code\datums\soullink.dm"
#include "code\datums\spawners_menu.dm"
#include "code\datums\station_state.dm"
#include "code\datums\tgs_event_handler.dm"