diff --git a/Content.Client/Robotics/Systems/RoboticsConsoleSystem.cs b/Content.Client/Robotics/Systems/RoboticsConsoleSystem.cs
new file mode 100644
index 0000000000..0219c965cd
--- /dev/null
+++ b/Content.Client/Robotics/Systems/RoboticsConsoleSystem.cs
@@ -0,0 +1,7 @@
+using Content.Shared.Robotics.Systems;
+
+namespace Content.Client.Robotics.Systems;
+
+public sealed class RoboticsConsoleSystem : SharedRoboticsConsoleSystem
+{
+}
diff --git a/Content.Client/Robotics/UI/RoboticsConsoleBoundUserInterface.cs b/Content.Client/Robotics/UI/RoboticsConsoleBoundUserInterface.cs
new file mode 100644
index 0000000000..6185979eee
--- /dev/null
+++ b/Content.Client/Robotics/UI/RoboticsConsoleBoundUserInterface.cs
@@ -0,0 +1,50 @@
+using Content.Shared.Robotics;
+using Robust.Client.GameObjects;
+
+namespace Content.Client.Robotics.UI;
+
+public sealed class RoboticsConsoleBoundUserInterface : BoundUserInterface
+{
+ [ViewVariables]
+ public RoboticsConsoleWindow _window = default!;
+
+ public RoboticsConsoleBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = new RoboticsConsoleWindow(Owner);
+ _window.OnDisablePressed += address =>
+ {
+ SendMessage(new RoboticsConsoleDisableMessage(address));
+ };
+ _window.OnDestroyPressed += address =>
+ {
+ SendMessage(new RoboticsConsoleDestroyMessage(address));
+ };
+ _window.OnClose += Close;
+
+ _window.OpenCentered();
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (state is not RoboticsConsoleState cast)
+ return;
+
+ _window?.UpdateState(cast);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ _window?.Dispose();
+ }
+}
diff --git a/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml b/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml
new file mode 100644
index 0000000000..a3b3978790
--- /dev/null
+++ b/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml.cs b/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml.cs
new file mode 100644
index 0000000000..3555099370
--- /dev/null
+++ b/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml.cs
@@ -0,0 +1,148 @@
+using Content.Client.Stylesheets;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Lock;
+using Content.Shared.Robotics;
+using Content.Shared.Robotics.Components;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Robotics.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class RoboticsConsoleWindow : FancyWindow
+{
+ [Dependency] private readonly IEntityManager _entMan = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ private readonly LockSystem _lock;
+ private readonly SpriteSystem _sprite;
+
+ public Action? OnDisablePressed;
+ public Action? OnDestroyPressed;
+
+ private Entity _console;
+ private string? _selected;
+ private Dictionary _cyborgs = new();
+
+ public RoboticsConsoleWindow(EntityUid console)
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ _lock = _entMan.System();
+ _sprite = _entMan.System();
+
+ _console = (console, _entMan.GetComponent(console), null);
+ _entMan.TryGetComponent(_console, out _console.Comp2);
+
+ Cyborgs.OnItemSelected += args =>
+ {
+ if (Cyborgs[args.ItemIndex].Metadata is not string address)
+ return;
+
+ _selected = address;
+ PopulateData();
+ };
+ Cyborgs.OnItemDeselected += _ =>
+ {
+ _selected = null;
+ PopulateData();
+ };
+
+ // these won't throw since buttons are only visible if a borg is selected
+ DisableButton.OnPressed += _ =>
+ {
+ OnDisablePressed?.Invoke(_selected!);
+ };
+ DestroyButton.OnPressed += _ =>
+ {
+ OnDestroyPressed?.Invoke(_selected!);
+ };
+
+ // cant put multiple styles in xaml for some reason
+ DestroyButton.StyleClasses.Add(StyleBase.ButtonCaution);
+ }
+
+ public void UpdateState(RoboticsConsoleState state)
+ {
+ _cyborgs = state.Cyborgs;
+
+ // clear invalid selection
+ if (_selected is {} selected && !_cyborgs.ContainsKey(selected))
+ _selected = null;
+
+ var hasCyborgs = _cyborgs.Count > 0;
+ NoCyborgs.Visible = !hasCyborgs;
+ CyborgsContainer.Visible = hasCyborgs;
+ PopulateCyborgs();
+
+ PopulateData();
+
+ var locked = _lock.IsLocked((_console, _console.Comp2));
+ DangerZone.Visible = !locked;
+ LockedMessage.Visible = locked;
+ }
+
+ private void PopulateCyborgs()
+ {
+ // _selected might get set to null when recreating so copy it first
+ var selected = _selected;
+ Cyborgs.Clear();
+ foreach (var (address, data) in _cyborgs)
+ {
+ var item = Cyborgs.AddItem(data.Name, _sprite.Frame0(data.ChassisSprite!), metadata: address);
+ item.Selected = address == selected;
+ }
+ _selected = selected;
+ }
+
+ private void PopulateData()
+ {
+ if (_selected is not {} selected)
+ {
+ SelectCyborg.Visible = true;
+ BorgContainer.Visible = false;
+ return;
+ }
+
+ SelectCyborg.Visible = false;
+ BorgContainer.Visible = true;
+
+ var data = _cyborgs[selected];
+ var model = data.ChassisName;
+
+ BorgSprite.Texture = _sprite.Frame0(data.ChassisSprite!);
+
+ var batteryColor = data.Charge switch {
+ < 0.2f => "red",
+ < 0.4f => "orange",
+ < 0.6f => "yellow",
+ < 0.8f => "green",
+ _ => "blue"
+ };
+
+ var text = new FormattedMessage();
+ text.PushMarkup(Loc.GetString("robotics-console-model", ("name", model)));
+ text.AddMarkup(Loc.GetString("robotics-console-designation"));
+ text.AddText($" {data.Name}\n"); // prevent players trolling by naming borg [color=red]satan[/color]
+ text.PushMarkup(Loc.GetString("robotics-console-battery", ("charge", (int) (data.Charge * 100f)), ("color", batteryColor)));
+ text.PushMarkup(Loc.GetString("robotics-console-brain", ("brain", data.HasBrain)));
+ text.AddMarkup(Loc.GetString("robotics-console-modules", ("count", data.ModuleCount)));
+ BorgInfo.SetMessage(text);
+
+ // how the turntables
+ DisableButton.Disabled = !data.HasBrain;
+ DestroyButton.Disabled = _timing.CurTime < _console.Comp1.NextDestroy;
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ DestroyButton.Disabled = _timing.CurTime < _console.Comp1.NextDestroy;
+ }
+}
diff --git a/Content.Server/Robotics/Systems/RoboticsConsoleSystem.cs b/Content.Server/Robotics/Systems/RoboticsConsoleSystem.cs
new file mode 100644
index 0000000000..916694fdd8
--- /dev/null
+++ b/Content.Server/Robotics/Systems/RoboticsConsoleSystem.cs
@@ -0,0 +1,146 @@
+using Content.Server.Administration.Logs;
+using Content.Server.DeviceNetwork;
+using Content.Server.DeviceNetwork.Systems;
+using Content.Server.Radio.EntitySystems;
+using Content.Shared.Lock;
+using Content.Shared.Database;
+using Content.Shared.DeviceNetwork;
+using Content.Shared.Robotics;
+using Content.Shared.Robotics.Components;
+using Content.Shared.Robotics.Systems;
+using Robust.Server.GameObjects;
+using Robust.Shared.Timing;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Content.Server.Research.Systems;
+
+///
+/// Handles UI and state receiving for the robotics control console.
+/// BorgTransponderComponent broadcasts state from the station's borgs to consoles.
+///
+public sealed class RoboticsConsoleSystem : SharedRoboticsConsoleSystem
+{
+ [Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
+ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly LockSystem _lock = default!;
+ [Dependency] private readonly RadioSystem _radio = default!;
+ [Dependency] private readonly UserInterfaceSystem _ui = default!;
+
+ // almost never timing out more than 1 per tick so initialize with that capacity
+ private List _removing = new(1);
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnPacketReceived);
+ Subs.BuiEvents(RoboticsConsoleUiKey.Key, subs =>
+ {
+ subs.Event(OnOpened);
+ subs.Event(OnDisable);
+ subs.Event(OnDestroy);
+ // TODO: camera stuff
+ });
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var now = _timing.CurTime;
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ // remove cyborgs that havent pinged in a while
+ _removing.Clear();
+ foreach (var (address, data) in comp.Cyborgs)
+ {
+ if (now >= data.Timeout)
+ _removing.Add(address);
+ }
+
+ // needed to prevent modifying while iterating it
+ foreach (var address in _removing)
+ {
+ comp.Cyborgs.Remove(address);
+ }
+
+ if (_removing.Count > 0)
+ UpdateUserInterface((uid, comp));
+ }
+ }
+
+ private void OnPacketReceived(Entity ent, ref DeviceNetworkPacketEvent args)
+ {
+ var payload = args.Data;
+ if (!payload.TryGetValue(DeviceNetworkConstants.Command, out string? command))
+ return;
+ if (command != DeviceNetworkConstants.CmdUpdatedState)
+ return;
+
+ if (!payload.TryGetValue(RoboticsConsoleConstants.NET_CYBORG_DATA, out CyborgControlData? data))
+ return;
+
+ var real = data.Value;
+ real.Timeout = _timing.CurTime + ent.Comp.Timeout;
+ ent.Comp.Cyborgs[args.SenderAddress] = real;
+
+ UpdateUserInterface(ent);
+ }
+
+ private void OnOpened(Entity ent, ref BoundUIOpenedEvent args)
+ {
+ UpdateUserInterface(ent);
+ }
+
+ private void OnDisable(Entity ent, ref RoboticsConsoleDisableMessage args)
+ {
+ if (_lock.IsLocked(ent.Owner))
+ return;
+
+ if (!ent.Comp.Cyborgs.TryGetValue(args.Address, out var data))
+ return;
+
+ var payload = new NetworkPayload()
+ {
+ [DeviceNetworkConstants.Command] = RoboticsConsoleConstants.NET_DISABLE_COMMAND
+ };
+
+ _deviceNetwork.QueuePacket(ent, args.Address, payload);
+ _adminLogger.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(args.Actor):user} disabled borg {data.Name} with address {args.Address}");
+ }
+
+ private void OnDestroy(Entity ent, ref RoboticsConsoleDestroyMessage args)
+ {
+ if (_lock.IsLocked(ent.Owner))
+ return;
+
+ var now = _timing.CurTime;
+ if (now < ent.Comp.NextDestroy)
+ return;
+
+ if (!ent.Comp.Cyborgs.Remove(args.Address, out var data))
+ return;
+
+ var payload = new NetworkPayload()
+ {
+ [DeviceNetworkConstants.Command] = RoboticsConsoleConstants.NET_DESTROY_COMMAND
+ };
+
+ _deviceNetwork.QueuePacket(ent, args.Address, payload);
+
+ var message = Loc.GetString(ent.Comp.DestroyMessage, ("name", data.Name));
+ _radio.SendRadioMessage(ent, message, ent.Comp.RadioChannel, ent);
+ _adminLogger.Add(LogType.Action, LogImpact.Extreme, $"{ToPrettyString(args.Actor):user} destroyed borg {data.Name} with address {args.Address}");
+
+ ent.Comp.NextDestroy = now + ent.Comp.DestroyCooldown;
+ Dirty(ent, ent.Comp);
+ }
+
+ private void UpdateUserInterface(Entity ent)
+ {
+ var state = new RoboticsConsoleState(ent.Comp.Cyborgs);
+ _ui.SetUiState(ent.Owner, RoboticsConsoleUiKey.Key, state);
+ }
+}
diff --git a/Content.Server/Silicons/Borgs/BorgSystem.Transponder.cs b/Content.Server/Silicons/Borgs/BorgSystem.Transponder.cs
new file mode 100644
index 0000000000..1c10cbe667
--- /dev/null
+++ b/Content.Server/Silicons/Borgs/BorgSystem.Transponder.cs
@@ -0,0 +1,107 @@
+using Content.Shared.DeviceNetwork;
+using Content.Shared.Emag.Components;
+using Content.Shared.Popups;
+using Content.Shared.Robotics;
+using Content.Shared.Silicons.Borgs.Components;
+using Content.Server.DeviceNetwork;
+using Content.Server.DeviceNetwork.Components;
+using Content.Server.DeviceNetwork.Systems;
+using Content.Server.Explosion.Components;
+
+namespace Content.Server.Silicons.Borgs;
+
+///
+public sealed partial class BorgSystem
+{
+ private void InitializeTransponder()
+ {
+ SubscribeLocalEvent(OnPacketReceived);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var now = _timing.CurTime;
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp, out var chassis, out var device, out var meta))
+ {
+ if (now < comp.NextBroadcast)
+ continue;
+
+ var charge = 0f;
+ if (_powerCell.TryGetBatteryFromSlot(uid, out var battery))
+ charge = battery.CurrentCharge / battery.MaxCharge;
+
+ var data = new CyborgControlData(
+ comp.Sprite,
+ comp.Name,
+ meta.EntityName,
+ charge,
+ chassis.ModuleCount,
+ chassis.BrainEntity != null);
+
+ var payload = new NetworkPayload()
+ {
+ [DeviceNetworkConstants.Command] = DeviceNetworkConstants.CmdUpdatedState,
+ [RoboticsConsoleConstants.NET_CYBORG_DATA] = data
+ };
+ _deviceNetwork.QueuePacket(uid, null, payload, device: device);
+
+ comp.NextBroadcast = now + comp.BroadcastDelay;
+ }
+ }
+
+ private void OnPacketReceived(Entity ent, ref DeviceNetworkPacketEvent args)
+ {
+ var payload = args.Data;
+ if (!payload.TryGetValue(DeviceNetworkConstants.Command, out string? command))
+ return;
+
+ if (command == RoboticsConsoleConstants.NET_DISABLE_COMMAND)
+ Disable(ent);
+ else if (command == RoboticsConsoleConstants.NET_DESTROY_COMMAND)
+ Destroy(ent.Owner);
+ }
+
+ private void Disable(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp2) || ent.Comp2.BrainEntity is not {} brain)
+ return;
+
+ // this won't exactly be stealthy but if you are malf its better than actually disabling you
+ if (CheckEmagged(ent, "disabled"))
+ return;
+
+ var message = Loc.GetString(ent.Comp1.DisabledPopup, ("name", Name(ent)));
+ Popup.PopupEntity(message, ent);
+ _container.Remove(brain, ent.Comp2.BrainContainer);
+ }
+
+ private void Destroy(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return;
+
+ // this is stealthy until someone realises you havent exploded
+ if (CheckEmagged(ent, "destroyed"))
+ {
+ // prevent reappearing on the console a few seconds later
+ RemComp(ent);
+ return;
+ }
+
+ _explosion.TriggerExplosive(ent, ent.Comp, delete: false);
+ }
+
+ private bool CheckEmagged(EntityUid uid, string name)
+ {
+ if (HasComp(uid))
+ {
+ Popup.PopupEntity(Loc.GetString($"borg-transponder-emagged-{name}-popup"), uid, uid, PopupType.LargeCaution);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/Content.Server/Silicons/Borgs/BorgSystem.cs b/Content.Server/Silicons/Borgs/BorgSystem.cs
index 0f14fef0ed..ceab044d4c 100644
--- a/Content.Server/Silicons/Borgs/BorgSystem.cs
+++ b/Content.Server/Silicons/Borgs/BorgSystem.cs
@@ -1,6 +1,8 @@
using Content.Server.Actions;
using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
+using Content.Server.DeviceNetwork.Systems;
+using Content.Server.Explosion.EntitySystems;
using Content.Server.Hands.Systems;
using Content.Server.PowerCell;
using Content.Shared.UserInterface;
@@ -26,6 +28,7 @@ using Robust.Server.GameObjects;
using Robust.Shared.Containers;
using Robust.Shared.Player;
using Robust.Shared.Random;
+using Robust.Shared.Timing;
namespace Content.Server.Silicons.Borgs;
@@ -34,10 +37,13 @@ public sealed partial class BorgSystem : SharedBorgSystem
{
[Dependency] private readonly IAdminLogManager _adminLog = default!;
[Dependency] private readonly IBanManager _banManager = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly SharedAccessSystem _access = default!;
[Dependency] private readonly ActionsSystem _actions = default!;
[Dependency] private readonly AlertsSystem _alerts = default!;
+ [Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
+ [Dependency] private readonly ExplosionSystem _explosion = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly HandsSystem _hands = default!;
[Dependency] private readonly MetaDataSystem _metaData = default!;
@@ -73,6 +79,7 @@ public sealed partial class BorgSystem : SharedBorgSystem
InitializeModules();
InitializeMMI();
InitializeUI();
+ InitializeTransponder();
}
private void OnMapInit(EntityUid uid, BorgChassisComponent component, MapInitEvent args)
diff --git a/Content.Shared/Lock/LockComponent.cs b/Content.Shared/Lock/LockComponent.cs
index 5587fc2698..e3e2bc6df1 100644
--- a/Content.Shared/Lock/LockComponent.cs
+++ b/Content.Shared/Lock/LockComponent.cs
@@ -21,12 +21,18 @@ public sealed partial class LockComponent : Component
public bool Locked = true;
///
- /// Whether or not the lock is toggled by simply clicking.
+ /// Whether or not the lock is locked by simply clicking.
///
[DataField("lockOnClick"), ViewVariables(VVAccess.ReadWrite)]
[AutoNetworkedField]
public bool LockOnClick;
+ ///
+ /// Whether or not the lock is unlocked by simply clicking.
+ ///
+ [DataField, AutoNetworkedField]
+ public bool UnlockOnClick = true;
+
///
/// The sound played when unlocked.
///
diff --git a/Content.Shared/Lock/LockSystem.cs b/Content.Shared/Lock/LockSystem.cs
index 5644a6b02f..54f5d801ea 100644
--- a/Content.Shared/Lock/LockSystem.cs
+++ b/Content.Shared/Lock/LockSystem.cs
@@ -58,12 +58,12 @@ public sealed class LockSystem : EntitySystem
return;
// Only attempt an unlock by default on Activate
- if (lockComp.Locked)
+ if (lockComp.Locked && lockComp.UnlockOnClick)
{
TryUnlock(uid, args.User, lockComp);
args.Handled = true;
}
- else if (lockComp.LockOnClick)
+ else if (!lockComp.Locked && lockComp.LockOnClick)
{
TryLock(uid, args.User, lockComp);
args.Handled = true;
@@ -201,6 +201,18 @@ public sealed class LockSystem : EntitySystem
return true;
}
+ ///
+ /// Returns true if the entity is locked.
+ /// Entities with no lock component are considered unlocked.
+ ///
+ public bool IsLocked(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return false;
+
+ return ent.Comp.Locked;
+ }
+
///
/// Raises an event for other components to check whether or not
/// the entity can be locked in its current state.
diff --git a/Content.Shared/Robotics/Components/RoboticsConsoleComponent.cs b/Content.Shared/Robotics/Components/RoboticsConsoleComponent.cs
new file mode 100644
index 0000000000..4329e437a2
--- /dev/null
+++ b/Content.Shared/Robotics/Components/RoboticsConsoleComponent.cs
@@ -0,0 +1,53 @@
+using Content.Shared.Radio;
+using Content.Shared.Robotics;
+using Content.Shared.Robotics.Systems;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.Robotics.Components;
+
+///
+/// Robotics console for managing borgs.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedRoboticsConsoleSystem))]
+[AutoGenerateComponentState, AutoGenerateComponentPause]
+public sealed partial class RoboticsConsoleComponent : Component
+{
+ ///
+ /// Address and data of each cyborg.
+ ///
+ [DataField]
+ public Dictionary Cyborgs = new();
+
+ ///
+ /// After not responding for this length of time borgs are removed from the console.
+ ///
+ [DataField]
+ public TimeSpan Timeout = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Radio channel to send messages on.
+ ///
+ [DataField]
+ public ProtoId RadioChannel = "Science";
+
+ ///
+ /// Radio message sent when destroying a borg.
+ ///
+ [DataField]
+ public LocId DestroyMessage = "robotics-console-cyborg-destroyed";
+
+ ///
+ /// Cooldown on destroying borgs to prevent complete abuse.
+ ///
+ [DataField]
+ public TimeSpan DestroyCooldown = TimeSpan.FromSeconds(30);
+
+ ///
+ /// When a borg can next be destroyed.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ [AutoNetworkedField, AutoPausedField]
+ public TimeSpan NextDestroy = TimeSpan.Zero;
+}
diff --git a/Content.Shared/Robotics/RoboticsConsoleUi.cs b/Content.Shared/Robotics/RoboticsConsoleUi.cs
new file mode 100644
index 0000000000..1be89beff0
--- /dev/null
+++ b/Content.Shared/Robotics/RoboticsConsoleUi.cs
@@ -0,0 +1,126 @@
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Robotics;
+
+[Serializable, NetSerializable]
+public enum RoboticsConsoleUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed class RoboticsConsoleState : BoundUserInterfaceState
+{
+ ///
+ /// Map of device network addresses to cyborg data.
+ ///
+ public Dictionary Cyborgs;
+
+ public RoboticsConsoleState(Dictionary cyborgs)
+ {
+ Cyborgs = cyborgs;
+ }
+}
+
+///
+/// Message to disable the selected cyborg.
+///
+[Serializable, NetSerializable]
+public sealed class RoboticsConsoleDisableMessage : BoundUserInterfaceMessage
+{
+ public readonly string Address;
+
+ public RoboticsConsoleDisableMessage(string address)
+ {
+ Address = address;
+ }
+}
+
+///
+/// Message to destroy the selected cyborg.
+///
+[Serializable, NetSerializable]
+public sealed class RoboticsConsoleDestroyMessage : BoundUserInterfaceMessage
+{
+ public readonly string Address;
+
+ public RoboticsConsoleDestroyMessage(string address)
+ {
+ Address = address;
+ }
+}
+
+///
+/// All data a client needs to render the console UI for a single cyborg.
+/// Created by BorgTransponderComponent and sent to clients by RoboticsConsoleComponent.
+///
+[DataRecord, Serializable, NetSerializable]
+public record struct CyborgControlData
+{
+ ///
+ /// Texture of the borg chassis.
+ ///
+ [DataField(required: true)]
+ public SpriteSpecifier? ChassisSprite;
+
+ ///
+ /// Name of the borg chassis.
+ ///
+ [DataField(required: true)]
+ public string ChassisName = string.Empty;
+
+ ///
+ /// Name of the borg's entity, including its silicon id.
+ ///
+ [DataField(required: true)]
+ public string Name = string.Empty;
+
+ ///
+ /// Battery charge from 0 to 1.
+ ///
+ [DataField]
+ public float Charge;
+
+ ///
+ /// How many modules this borg has, just useful information for roboticists.
+ /// Lets them keep track of the latejoin borgs that need new modules and stuff.
+ ///
+ [DataField]
+ public int ModuleCount;
+
+ ///
+ /// Whether the borg has a brain installed or not.
+ ///
+ [DataField]
+ public bool HasBrain;
+
+ ///
+ /// When this cyborg's data will be deleted.
+ /// Set by the console when receiving the packet.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ public TimeSpan Timeout = TimeSpan.Zero;
+
+ public CyborgControlData(SpriteSpecifier? chassisSprite, string chassisName, string name, float charge, int moduleCount, bool hasBrain)
+ {
+ ChassisSprite = chassisSprite;
+ ChassisName = chassisName;
+ Name = name;
+ Charge = charge;
+ ModuleCount = moduleCount;
+ HasBrain = hasBrain;
+ }
+}
+
+public static class RoboticsConsoleConstants
+{
+ // broadcast by cyborgs on Robotics Console frequency
+ public const string NET_CYBORG_DATA = "cyborg-data";
+
+ // sent by robotics console to cyborgs on Cyborg Control frequency
+ public const string NET_DISABLE_COMMAND = "cyborg-disable";
+ public const string NET_DESTROY_COMMAND = "cyborg-destroy";
+}
diff --git a/Content.Shared/Robotics/Systems/SharedRoboticsConsoleSystem.cs b/Content.Shared/Robotics/Systems/SharedRoboticsConsoleSystem.cs
new file mode 100644
index 0000000000..25b3c5d07a
--- /dev/null
+++ b/Content.Shared/Robotics/Systems/SharedRoboticsConsoleSystem.cs
@@ -0,0 +1,8 @@
+namespace Content.Shared.Robotics.Systems;
+
+///
+/// Does nothing, only exists for access right now.
+///
+public abstract class SharedRoboticsConsoleSystem : EntitySystem
+{
+}
diff --git a/Content.Shared/Silicons/Borgs/Components/BorgTransponderComponent.cs b/Content.Shared/Silicons/Borgs/Components/BorgTransponderComponent.cs
new file mode 100644
index 0000000000..8c15e20d5d
--- /dev/null
+++ b/Content.Shared/Silicons/Borgs/Components/BorgTransponderComponent.cs
@@ -0,0 +1,43 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Silicons.Borgs.Components;
+
+///
+/// Periodically broadcasts borg data to robotics consoles.
+/// When not emagged, handles disabling and destroying commands as expected.
+///
+[RegisterComponent, Access(typeof(SharedBorgSystem))]
+public sealed partial class BorgTransponderComponent : Component
+{
+ ///
+ /// Sprite of the chassis to send.
+ ///
+ [DataField(required: true)]
+ public SpriteSpecifier? Sprite;
+
+ ///
+ /// Name of the chassis to send.
+ ///
+ [DataField(required: true)]
+ public string Name = string.Empty;
+
+ ///
+ /// Popup shown to everyone when a borg is disabled.
+ /// Gets passed a string "name".
+ ///
+ [DataField]
+ public LocId DisabledPopup = "borg-transponder-disabled-popup";
+
+ ///
+ /// How long to wait between each broadcast.
+ ///
+ [DataField]
+ public TimeSpan BroadcastDelay = TimeSpan.FromSeconds(5);
+
+ ///
+ /// When to next broadcast data.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ public TimeSpan NextBroadcast = TimeSpan.Zero;
+}
diff --git a/Resources/Locale/en-US/borg/borg.ftl b/Resources/Locale/en-US/borg/borg.ftl
index 2f51331a83..c9005eb796 100644
--- a/Resources/Locale/en-US/borg/borg.ftl
+++ b/Resources/Locale/en-US/borg/borg.ftl
@@ -17,3 +17,8 @@ borg-ui-no-brain = No brain present
borg-ui-remove-battery = Remove
borg-ui-modules-label = Modules:
borg-ui-module-counter = {$actual}/{$max}
+
+# Transponder
+borg-transponder-disabled-popup = A brain shoots out the top of {$name}!
+borg-transponder-emagged-disabled-popup = Your transponder's lights go out!
+borg-transponder-emagged-destroyed-popup = Your transponder's fuse blows!
diff --git a/Resources/Locale/en-US/devices/device-network.ftl b/Resources/Locale/en-US/devices/device-network.ftl
index 8ce90bb237..9ae101a1e8 100644
--- a/Resources/Locale/en-US/devices/device-network.ftl
+++ b/Resources/Locale/en-US/devices/device-network.ftl
@@ -7,6 +7,8 @@ device-frequency-prototype-name-mailing-units = Mailing Units
device-frequency-prototype-name-pdas = PDAs
device-frequency-prototype-name-fax = Fax
device-frequency-prototype-name-basic-device = Basic Devices
+device-frequency-prototype-name-cyborg-control = Cyborg Control
+device-frequency-prototype-name-robotics-console = Robotics Console
## camera frequencies
device-frequency-prototype-name-surveillance-camera-test = Subnet Test
diff --git a/Resources/Locale/en-US/research/components/robotics-console.ftl b/Resources/Locale/en-US/research/components/robotics-console.ftl
new file mode 100644
index 0000000000..978fa9a43c
--- /dev/null
+++ b/Resources/Locale/en-US/research/components/robotics-console.ftl
@@ -0,0 +1,19 @@
+robotics-console-window-title = Robotics Console
+robotics-console-no-cyborgs = No Cyborgs!
+
+robotics-console-select-cyborg = Select a cyborg above.
+robotics-console-model = [color=gray]Model:[/color] {$name}
+# name is not formatted to prevent players trolling
+robotics-console-designation = [color=gray]Designation:[/color]
+robotics-console-battery = [color=gray]Battery charge:[/color] [color={$color}]{$charge}[/color]%
+robotics-console-modules = [color=gray]Modules installed:[/color] {$count}
+robotics-console-brain = [color=gray]Brain installed:[/color] [color={$brain ->
+ [true] green]Yes
+ *[false] red]No
+}[/color]
+
+robotics-console-locked-message = Controls locked, swipe ID.
+robotics-console-disable = Disable
+robotics-console-destroy = Destroy
+
+robotics-console-cyborg-destroyed = The cyborg {$name} has been remotely destroyed.
diff --git a/Resources/Prototypes/Device/devicenet_frequencies.yml b/Resources/Prototypes/Device/devicenet_frequencies.yml
index db48d117e0..ecdbb3bb4c 100644
--- a/Resources/Prototypes/Device/devicenet_frequencies.yml
+++ b/Resources/Prototypes/Device/devicenet_frequencies.yml
@@ -75,6 +75,17 @@
name: device-frequency-prototype-name-crew-monitor
frequency: 1261
+# Cyborgs broadcast to consoles on this frequency
+- type: deviceFrequency
+ id: RoboticsConsole
+ name: device-frequency-prototype-name-robotics-console
+ frequency: 1291
+
+# Console sends commands to cyborgs on this frequency
+- type: deviceFrequency
+ id: CyborgControl
+ name: device-frequency-prototype-name-cyborg-control
+ frequency: 1292
# This frequency will likely have a LARGE number of listening entities. Please don't broadcast on this frequency.
- type: deviceFrequency
diff --git a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
index 2df281971a..187aeae265 100644
--- a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
+++ b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
@@ -217,9 +217,26 @@
- Cyborgs
- type: StepTriggerImmune
+- type: entity
+ abstract: true
+ id: BaseBorgTransponder
+ components:
+ - type: BorgTransponder
+ - type: DeviceNetwork
+ deviceNetId: Wireless
+ receiveFrequencyId: CyborgControl
+ transmitFrequencyId: RoboticsConsole
+ # explosion does most of its damage in the center and less at the edges
+ - type: Explosive
+ explosionType: Minibomb
+ totalIntensity: 30
+ intensitySlope: 20
+ maxIntensity: 20
+ canCreateVacuum: false # its for killing the borg not the station
+
- type: entity
id: BaseBorgChassisNT
- parent: BaseBorgChassis
+ parent: [BaseBorgChassis, BaseBorgTransponder]
abstract: true
components:
- type: NpcFactionMember
diff --git a/Resources/Prototypes/Entities/Mobs/Cyborgs/borg_chassis.yml b/Resources/Prototypes/Entities/Mobs/Cyborgs/borg_chassis.yml
index 9365c6da56..b7db886255 100644
--- a/Resources/Prototypes/Entities/Mobs/Cyborgs/borg_chassis.yml
+++ b/Resources/Prototypes/Entities/Mobs/Cyborgs/borg_chassis.yml
@@ -20,6 +20,11 @@
- BorgModuleGeneric
hasMindState: robot_e
noMindState: robot_e_r
+ - type: BorgTransponder
+ sprite:
+ sprite: Mobs/Silicon/chassis.rsi
+ state: robot
+ name: cyborg
- type: Construction
node: cyborg
- type: Speech
@@ -57,6 +62,11 @@
- BorgModuleCargo
hasMindState: miner_e
noMindState: miner_e_r
+ - type: BorgTransponder
+ sprite:
+ sprite: Mobs/Silicon/chassis.rsi
+ state: miner
+ name: salvage cyborg
- type: Construction
node: mining
- type: IntrinsicRadioTransmitter
@@ -100,6 +110,11 @@
- BorgModuleEngineering
hasMindState: engineer_e
noMindState: engineer_e_r
+ - type: BorgTransponder
+ sprite:
+ sprite: Mobs/Silicon/chassis.rsi
+ state: engineer
+ name: engineer cyborg
- type: Construction
node: engineer
- type: IntrinsicRadioTransmitter
@@ -151,6 +166,11 @@
- BorgModuleJanitor
hasMindState: janitor_e
noMindState: janitor_e_r
+ - type: BorgTransponder
+ sprite:
+ sprite: Mobs/Silicon/chassis.rsi
+ state: janitor
+ name: janitor cyborg
- type: Construction
node: janitor
- type: IntrinsicRadioTransmitter
@@ -202,6 +222,11 @@
- BorgModuleMedical
hasMindState: medical_e
noMindState: medical_e_r
+ - type: BorgTransponder
+ sprite:
+ sprite: Mobs/Silicon/chassis.rsi
+ state: medical
+ name: medical cyborg
- type: Construction
node: medical
- type: IntrinsicRadioTransmitter
@@ -250,6 +275,11 @@
- BorgModuleService
hasMindState: service_e
noMindState: service_e_r
+ - type: BorgTransponder
+ sprite:
+ sprite: Mobs/Silicon/chassis.rsi
+ state: service
+ name: service cyborg
- type: Construction
node: service
- type: IntrinsicRadioTransmitter
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml
index ba91f6bc53..93aff069f0 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml
@@ -384,3 +384,14 @@
state: cpu_engineering
- type: ComputerBoard
prototype: ComputerSensorMonitoring
+
+- type: entity
+ parent: BaseComputerCircuitboard
+ id: RoboticsConsoleCircuitboard
+ name: robotics control console board
+ description: A computer printed circuit board for a robotics control console.
+ components:
+ - type: Sprite
+ state: cpu_science
+ - type: ComputerBoard
+ prototype: ComputerRoboticsControl
diff --git a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
index 56570697df..b5c7a1a19c 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
@@ -1076,3 +1076,42 @@
- type: WiredNetworkConnection
- type: DeviceList
- type: AtmosDevice
+
+- type: entity
+ parent: BaseComputer
+ id: ComputerRoboticsControl
+ name: robotics control console
+ description: Used to remotely monitor, disable and destroy the station's cyborgs.
+ components:
+ - type: Sprite
+ layers:
+ - map: ["computerLayerBody"]
+ state: computer
+ - map: ["computerLayerKeyboard"]
+ state: generic_keyboard
+ - map: ["computerLayerScreen"]
+ state: robot
+ - map: ["computerLayerKeys"]
+ state: rd_key
+ - type: RoboticsConsole
+ - type: ActiveRadio
+ channels:
+ - Science
+ - type: ActivatableUI
+ key: enum.RoboticsConsoleUiKey.Key
+ - type: UserInterface
+ interfaces:
+ enum.RoboticsConsoleUiKey.Key:
+ type: RoboticsConsoleBoundUserInterface
+ - type: ApcPowerReceiver
+ powerLoad: 1000
+ - type: DeviceNetwork
+ deviceNetId: Wireless
+ receiveFrequencyId: RoboticsConsole
+ transmitFrequencyId: CyborgControl
+ - type: Computer
+ board: RoboticsConsoleCircuitboard
+ - type: AccessReader # only used for dangerous things
+ access: [["ResearchDirector"]]
+ - type: Lock
+ unlockOnClick: false