diff --git a/Content.Client/Entry/IgnoredComponents.cs b/Content.Client/Entry/IgnoredComponents.cs
index 8375ceadeb..3413c60249 100644
--- a/Content.Client/Entry/IgnoredComponents.cs
+++ b/Content.Client/Entry/IgnoredComponents.cs
@@ -303,6 +303,8 @@ namespace Content.Client.Entry
"ApcNetSwitch",
"HandLabeler",
"Label",
+ "Nuke",
+ "NukeCodePaper",
"GhostRadio",
"Armor",
"PneumaticCannon"
diff --git a/Content.Client/Nuke/NukeBoundUserInterface.cs b/Content.Client/Nuke/NukeBoundUserInterface.cs
new file mode 100644
index 0000000000..a578fd093b
--- /dev/null
+++ b/Content.Client/Nuke/NukeBoundUserInterface.cs
@@ -0,0 +1,79 @@
+using Content.Client.Traitor.Uplink;
+using Content.Shared.Nuke;
+using JetBrains.Annotations;
+using Robust.Client.GameObjects;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Localization;
+
+namespace Content.Client.Nuke
+{
+ [UsedImplicitly]
+ public class NukeBoundUserInterface : BoundUserInterface
+ {
+ private NukeMenu? _menu;
+
+ public NukeBoundUserInterface([NotNull] ClientUserInterfaceComponent owner, [NotNull] object uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ _menu = new NukeMenu();
+ _menu.OpenCentered();
+ _menu.OnClose += Close;
+
+ _menu.OnKeypadButtonPressed += i =>
+ {
+ SendMessage(new NukeKeypadMessage(i));
+ };
+ _menu.OnEnterButtonPressed += () =>
+ {
+ SendMessage(new NukeKeypadEnterMessage());
+ };
+ _menu.OnClearButtonPressed += () =>
+ {
+ SendMessage(new NukeKeypadClearMessage());
+ };
+
+ _menu.EjectButton.OnPressed += _ =>
+ {
+ SendMessage(new NukeEjectMessage());
+ };
+ _menu.AnchorButton.OnPressed += _ =>
+ {
+ SendMessage(new NukeAnchorMessage());
+ };
+ _menu.ArmButton.OnPressed += _ =>
+ {
+ SendMessage(new NukeArmedMessage());
+ };
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (_menu == null)
+ return;
+
+ switch (state)
+ {
+ case NukeUiState msg:
+ {
+ _menu.UpdateState(msg);
+ break;
+ }
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing)
+ return;
+
+ _menu?.Close();
+ _menu?.Dispose();
+ }
+ }
+}
diff --git a/Content.Client/Nuke/NukeMenu.xaml b/Content.Client/Nuke/NukeMenu.xaml
new file mode 100644
index 0000000000..3772d27501
--- /dev/null
+++ b/Content.Client/Nuke/NukeMenu.xaml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Nuke/NukeMenu.xaml.cs b/Content.Client/Nuke/NukeMenu.xaml.cs
new file mode 100644
index 0000000000..4c0e8b3500
--- /dev/null
+++ b/Content.Client/Nuke/NukeMenu.xaml.cs
@@ -0,0 +1,115 @@
+using System;
+using Content.Shared.Nuke;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Localization;
+
+namespace Content.Client.Nuke
+{
+ [GenerateTypedNameReferences]
+ public partial class NukeMenu : SS14Window
+ {
+ public event Action? OnKeypadButtonPressed;
+ public event Action? OnClearButtonPressed;
+ public event Action? OnEnterButtonPressed;
+
+ public NukeMenu()
+ {
+ RobustXamlLoader.Load(this);
+ FillKeypadGrid();
+ }
+
+ ///
+ /// Fill keypad buttons in keypad grid
+ ///
+ private void FillKeypadGrid()
+ {
+ // add 3 rows of keypad buttons (1-9)
+ for (var i = 1; i <= 9; i++)
+ {
+ AddKeypadButton(i);
+ }
+
+ // clear button
+ var clearBtn = new Button()
+ {
+ Text = "C"
+ };
+ clearBtn.OnPressed += _ => OnClearButtonPressed?.Invoke();
+ KeypadGrid.AddChild(clearBtn);
+
+ // zero button
+ AddKeypadButton(0);
+
+ // enter button
+ var enterBtn = new Button()
+ {
+ Text = "E"
+ };
+ enterBtn.OnPressed += _ => OnEnterButtonPressed?.Invoke();
+ KeypadGrid.AddChild(enterBtn);
+ }
+
+ private void AddKeypadButton(int i)
+ {
+ var btn = new Button()
+ {
+ Text = i.ToString()
+ };
+
+ btn.OnPressed += _ => OnKeypadButtonPressed?.Invoke(i);
+ KeypadGrid.AddChild(btn);
+ }
+
+ public void UpdateState(NukeUiState state)
+ {
+ string firstMsg, secondMsg;
+ switch (state.Status)
+ {
+ case NukeStatus.AWAIT_DISK:
+ firstMsg = Loc.GetString("nuke-user-interface-first-status-device-locked");
+ secondMsg = Loc.GetString("nuke-user-interface-second-status-await-disk");
+ break;
+ case NukeStatus.AWAIT_CODE:
+ firstMsg = Loc.GetString("nuke-user-interface-first-status-input-code");
+ secondMsg = Loc.GetString("nuke-user-interface-second-status-current-code",
+ ("code", VisualizeCode(state.EnteredCodeLength, state.MaxCodeLength)));
+ break;
+ case NukeStatus.AWAIT_ARM:
+ firstMsg = Loc.GetString("nuke-user-interface-first-status-device-ready");
+ secondMsg = Loc.GetString("nuke-user-interface-second-status-time",
+ ("time", state.RemainingTime));
+ break;
+ case NukeStatus.ARMED:
+ firstMsg = Loc.GetString("nuke-user-interface-first-status-device-armed");
+ secondMsg = Loc.GetString("nuke-user-interface-second-status-time",
+ ("time", state.RemainingTime));
+ break;
+ default:
+ // shouldn't normally be here
+ firstMsg = Loc.GetString("nuke-user-interface-status-error");
+ secondMsg = Loc.GetString("nuke-user-interface-status-error");
+ break;
+ }
+
+ FirstStatusLabel.Text = firstMsg;
+ SecondStatusLabel.Text = secondMsg;
+
+ EjectButton.Disabled = !state.DiskInserted;
+ AnchorButton.Disabled = !state.DiskInserted;
+ AnchorButton.Pressed = state.IsAnchored;
+ ArmButton.Disabled = !state.AllowArm;
+ }
+
+ private string VisualizeCode(int codeLength, int maxLength)
+ {
+ var code = new string('*', codeLength);
+ var blanksCount = maxLength - codeLength;
+ var blanks = new string('_', blanksCount);
+ return code + blanks;
+ }
+ }
+}
diff --git a/Content.Server/Nuke/Commands/SendNukeCodesCommand.cs b/Content.Server/Nuke/Commands/SendNukeCodesCommand.cs
new file mode 100644
index 0000000000..f7c85c4489
--- /dev/null
+++ b/Content.Server/Nuke/Commands/SendNukeCodesCommand.cs
@@ -0,0 +1,22 @@
+using Content.Server.Administration;
+using Content.Shared.Administration;
+using JetBrains.Annotations;
+using Robust.Shared.Console;
+using Robust.Shared.GameObjects;
+
+namespace Content.Server.Nuke.Commands
+{
+ [UsedImplicitly]
+ [AdminCommand(AdminFlags.Fun)]
+ public class SendNukeCodesCommand : IConsoleCommand
+ {
+ public string Command => "nukecodes";
+ public string Description => "Send nuke codes to the communication console";
+ public string Help => "nukecodes";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ EntitySystem.Get().SendNukeCodes();
+ }
+ }
+}
diff --git a/Content.Server/Nuke/Commands/ToggleNukeCommand.cs b/Content.Server/Nuke/Commands/ToggleNukeCommand.cs
new file mode 100644
index 0000000000..64003964d7
--- /dev/null
+++ b/Content.Server/Nuke/Commands/ToggleNukeCommand.cs
@@ -0,0 +1,63 @@
+using Content.Server.Administration;
+using Content.Shared.Administration;
+using JetBrains.Annotations;
+using Robust.Shared.Console;
+using Robust.Shared.GameObjects;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+using System.Linq;
+
+namespace Content.Server.Nuke.Commands
+{
+ [UsedImplicitly]
+ [AdminCommand(AdminFlags.Fun)]
+ public class ToggleNukeCommand : IConsoleCommand
+ {
+ public string Command => "nukearm";
+ public string Description => "Toggle nuclear bomb timer. You can set timer directly. Uid is optional.";
+ public string Help => "nukearm ";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ EntityUid bombUid;
+ NukeComponent? bomb = null;
+
+ if (args.Length >= 2)
+ {
+ if (!EntityUid.TryParse(args[1], out bombUid))
+ {
+ shell.WriteError(Loc.GetString("shell-entity-uid-must-be-number"));
+ return;
+ }
+ }
+ else
+ {
+ var entManager = IoCManager.Resolve();
+ var bombs = entManager.EntityQuery();
+
+ bomb = bombs.FirstOrDefault();
+ if (bomb == null)
+ {
+ shell.WriteError("Can't find any entity with a NukeComponent");
+ return;
+ }
+
+ bombUid = bomb.OwnerUid;
+ }
+
+ var nukeSys = EntitySystem.Get();
+ if (args.Length >= 1)
+ {
+ if (!float.TryParse(args[0], out var timer))
+ {
+ shell.WriteError("shell-argument-must-be-number");
+ return;
+ }
+
+ nukeSys.SetRemainingTime(bombUid, timer, bomb);
+ }
+
+ nukeSys.ToggleBomb(bombUid, bomb);
+ }
+ }
+}
diff --git a/Content.Server/Nuke/NukeCodePaperComponent.cs b/Content.Server/Nuke/NukeCodePaperComponent.cs
new file mode 100644
index 0000000000..1c4cbb9767
--- /dev/null
+++ b/Content.Server/Nuke/NukeCodePaperComponent.cs
@@ -0,0 +1,14 @@
+using Robust.Shared.GameObjects;
+
+namespace Content.Server.Nuke
+{
+ ///
+ /// Paper with a written nuclear code in it.
+ /// Can be used in mapping or admins spawn.
+ ///
+ [RegisterComponent]
+ public class NukeCodePaperComponent : Component
+ {
+ public override string Name => "NukeCodePaper";
+ }
+}
diff --git a/Content.Server/Nuke/NukeCodePaperSystem.cs b/Content.Server/Nuke/NukeCodePaperSystem.cs
new file mode 100644
index 0000000000..b68984da33
--- /dev/null
+++ b/Content.Server/Nuke/NukeCodePaperSystem.cs
@@ -0,0 +1,27 @@
+using Content.Server.Paper;
+using Robust.Shared.GameObjects;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+
+namespace Content.Server.Nuke
+{
+ public class NukeCodePaperSystem : EntitySystem
+ {
+ [Dependency] private readonly NukeCodeSystem _codes = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnMapInit);
+ }
+
+ private void OnMapInit(EntityUid uid, NukeCodePaperComponent component, MapInitEvent args)
+ {
+ PaperComponent? paper = null;
+ if (!Resolve(uid, ref paper))
+ return;
+
+ paper.Content += _codes.Code;
+ }
+ }
+}
diff --git a/Content.Server/Nuke/NukeCodeSystem.cs b/Content.Server/Nuke/NukeCodeSystem.cs
new file mode 100644
index 0000000000..18bd213ce9
--- /dev/null
+++ b/Content.Server/Nuke/NukeCodeSystem.cs
@@ -0,0 +1,88 @@
+using Content.Server.Chat.Managers;
+using Content.Server.Communications;
+using Content.Shared.GameTicking;
+using Robust.Shared.GameObjects;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+using Robust.Shared.Random;
+
+namespace Content.Server.Nuke
+{
+ ///
+ /// Nuclear code is generated once per round
+ /// One code works for all nukes
+ ///
+ public class NukeCodeSystem : EntitySystem
+ {
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly IChatManager _chat = default!;
+
+ public const int CodeLength = 6;
+ public string Code { get; private set; } = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ GenerateNewCode();
+
+ SubscribeLocalEvent(OnRestart);
+ }
+
+ private void OnRestart(RoundRestartCleanupEvent ev)
+ {
+ GenerateNewCode();
+ }
+
+ ///
+ /// Checks if code is equal to current bombs code
+ ///
+ public bool IsCodeValid(string code)
+ {
+ return code == Code;
+ }
+
+ ///
+ /// Generate a new nuclear bomb code. Replacing old one.
+ ///
+ public void GenerateNewCode()
+ {
+ var ret = "";
+ for (int i = 0; i < CodeLength; i++)
+ {
+ var c = (char) _random.Next('0', '9' + 1);
+ ret += c;
+ }
+
+ Code = ret;
+ }
+
+ ///
+ /// Send a nuclear code to all communication consoles
+ ///
+ /// True if at least one console received codes
+ public bool SendNukeCodes()
+ {
+ // todo: this should probably be handled by fax system
+ var wasSent = false;
+ var consoles = EntityManager.EntityQuery();
+ foreach (var console in consoles)
+ {
+ if (!EntityManager.TryGetComponent(console.OwnerUid, out TransformComponent? transform))
+ continue;
+
+ var consolePos = transform.MapPosition;
+ EntityManager.SpawnEntity("NukeCodePaper", consolePos);
+
+ wasSent = true;
+ }
+
+ if (wasSent)
+ {
+ var msg = Loc.GetString("nuke-component-announcement-send-codes");
+ _chat.DispatchStationAnnouncement(msg);
+ }
+
+ return wasSent;
+ }
+ }
+}
diff --git a/Content.Server/Nuke/NukeComponent.cs b/Content.Server/Nuke/NukeComponent.cs
new file mode 100644
index 0000000000..abb9747d59
--- /dev/null
+++ b/Content.Server/Nuke/NukeComponent.cs
@@ -0,0 +1,101 @@
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.Nuke;
+using Content.Shared.Sound;
+using Robust.Shared.Analyzers;
+using Robust.Shared.Audio;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Serialization.Manager.Attributes;
+using Robust.Shared.ViewVariables;
+
+namespace Content.Server.Nuke
+{
+ ///
+ /// Nuclear device that can devistate an entire station.
+ /// Basicaly a station self-destruction mechanism.
+ /// To activate it, user needs to insert an authorization disk and enter a secret code.
+ ///
+ [RegisterComponent]
+ [Friend(typeof(NukeSystem))]
+ public class NukeComponent : Component
+ {
+ public override string Name => "Nuke";
+
+ ///
+ /// Default bomb timer value in seconds.
+ ///
+ [DataField("timer")]
+ [ViewVariables(VVAccess.ReadWrite)]
+ public int Timer = 180;
+
+ ///
+ /// Slot name for to store nuclear disk inside bomb.
+ /// See for mor info.
+ ///
+ [DataField("slot")]
+ public string DiskSlotName = "DiskSlot";
+
+ ///
+ /// Annihilation radius in which all human players will be gibed
+ ///
+ [DataField("blastRadius")]
+ [ViewVariables(VVAccess.ReadWrite)]
+ public int BlastRadius = 200;
+
+ ///
+ /// After this time nuke will play last alert sound
+ ///
+ [DataField("alertTime")]
+ public float AlertSoundTime = 10.0f;
+
+ [DataField("keypadPressSound")]
+ public SoundSpecifier KeypadPressSound = new SoundPathSpecifier("/Audio/Machines/Nuke/general_beep.ogg");
+
+ [DataField("accessGrantedSound")]
+ public SoundSpecifier AccessGrantedSound = new SoundPathSpecifier("/Audio/Machines/Nuke/general_beep.ogg");
+
+ [DataField("accessDeniedSound")]
+ public SoundSpecifier AccessDeniedSound = new SoundPathSpecifier("/Audio/Machines/Nuke/angry_beep.ogg");
+
+ [DataField("alertSound")]
+ public SoundSpecifier AlertSound = new SoundPathSpecifier("/Audio/Machines/alarm.ogg");
+
+ [DataField("armSound")]
+ public SoundSpecifier ArmSound = new SoundPathSpecifier("/Audio/Misc/notice1.ogg");
+
+ [DataField("disarmSound")]
+ public SoundSpecifier DisarmSound = new SoundPathSpecifier("/Audio/Misc/notice2.ogg");
+
+ ///
+ /// Time until explosion in seconds.
+ ///
+ [ViewVariables]
+ public float RemainingTime;
+
+ ///
+ /// Does bomb contains valid entity inside ?
+ /// If it is, user can anchor bomb or enter nuclear code to arm it.
+ ///
+ [ViewVariables]
+ public bool DiskInserted = false;
+
+ ///
+ /// Curent nuclear code buffer. Entered manually by players.
+ /// If valid it will allow arm/disarm bomb.
+ ///
+ [ViewVariables]
+ public string EnteredCode = "";
+
+ ///
+ /// Current status of a nuclear bomb.
+ ///
+ [ViewVariables]
+ public NukeStatus Status = NukeStatus.AWAIT_DISK;
+
+ ///
+ /// Check if nuke has already played last alert sound
+ ///
+ public bool PlayedAlertSound = false;
+
+ public IPlayingAudioStream? AlertAudioStream = default;
+ }
+}
diff --git a/Content.Server/Nuke/NukeSystem.cs b/Content.Server/Nuke/NukeSystem.cs
new file mode 100644
index 0000000000..b78bbc7478
--- /dev/null
+++ b/Content.Server/Nuke/NukeSystem.cs
@@ -0,0 +1,437 @@
+using Content.Server.Construction.Components;
+using Content.Server.Popups;
+using Content.Server.UserInterface;
+using Content.Shared.ActionBlocker;
+using Content.Shared.Body.Components;
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.Interaction;
+using Content.Shared.Interaction.Helpers;
+using Content.Shared.Nuke;
+using Content.Server.Chat.Managers;
+using Robust.Server.GameObjects;
+using Robust.Server.Player;
+using Robust.Shared.GameObjects;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+using Robust.Shared.Player;
+using System.Collections.Generic;
+using Content.Server.Coordinates.Helpers;
+using Content.Shared.Audio;
+using Content.Shared.Sound;
+using Robust.Shared.Audio;
+
+namespace Content.Server.Nuke
+{
+ public class NukeSystem : EntitySystem
+ {
+ [Dependency] private readonly NukeCodeSystem _codes = default!;
+ [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
+ [Dependency] private readonly SharedItemSlotsSystem _itemSlots = default!;
+ [Dependency] private readonly PopupSystem _popups = default!;
+ [Dependency] private readonly IEntityLookup _lookup = default!;
+ [Dependency] private readonly IChatManager _chat = default!;
+
+ private readonly HashSet _tickingBombs = new();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(OnRemove);
+ SubscribeLocalEvent(OnActivate);
+ SubscribeLocalEvent(OnItemSlotChanged);
+
+ // anchoring logic
+ SubscribeLocalEvent(OnAnchorAttempt);
+ SubscribeLocalEvent(OnUnanchorAttempt);
+ SubscribeLocalEvent(OnWasAnchored);
+ SubscribeLocalEvent(OnWasUnanchored);
+
+ // ui events
+ SubscribeLocalEvent(OnEjectButtonPressed);
+ SubscribeLocalEvent(OnAnchorButtonPressed);
+ SubscribeLocalEvent(OnArmButtonPressed);
+ SubscribeLocalEvent(OnKeypadButtonPressed);
+ SubscribeLocalEvent(OnClearButtonPressed);
+ SubscribeLocalEvent(OnEnterButtonPressed);
+ }
+
+ private void OnInit(EntityUid uid, NukeComponent component, ComponentInit args)
+ {
+ component.RemainingTime = component.Timer;
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+ foreach (var uid in _tickingBombs)
+ {
+ if (!EntityManager.TryGetComponent(uid, out NukeComponent nuke))
+ continue;
+
+ nuke.RemainingTime -= frameTime;
+
+ // play alert sound if time is running out
+ if (nuke.RemainingTime <= nuke.AlertSoundTime && !nuke.PlayedAlertSound)
+ {
+ nuke.AlertAudioStream = SoundSystem.Play(Filter.Broadcast(), nuke.AlertSound.GetSound());
+ nuke.PlayedAlertSound = true;
+ }
+
+ if (nuke.RemainingTime <= 0)
+ {
+ nuke.RemainingTime = 0;
+ ActivateBomb(uid, nuke);
+ }
+ else
+ {
+ UpdateUserInterface(uid, nuke);
+ }
+ }
+ }
+
+ private void OnRemove(EntityUid uid, NukeComponent component, ComponentRemove args)
+ {
+ _tickingBombs.Remove(uid);
+ }
+
+ private void OnItemSlotChanged(EntityUid uid, NukeComponent component, ItemSlotChangedEvent args)
+ {
+ if (args.SlotName != component.DiskSlotName)
+ return;
+
+ component.DiskInserted = args.ContainedItem != null;
+ UpdateStatus(uid, component);
+ UpdateUserInterface(uid, component);
+ }
+
+ private void OnActivate(EntityUid uid, NukeComponent component, ActivateInWorldEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ // standard interactions check
+ if (!args.InRangeUnobstructed())
+ return;
+ if (!_actionBlocker.CanInteract(args.User.Uid) || !_actionBlocker.CanUse(args.User.Uid))
+ return;
+
+ if (!EntityManager.TryGetComponent(args.User.Uid, out ActorComponent? actor))
+ return;
+
+ ShowUI(uid, actor.PlayerSession, component);
+ args.Handled = true;
+ }
+
+ #region Anchor
+ private void OnAnchorAttempt(EntityUid uid, NukeComponent component, AnchorAttemptEvent args)
+ {
+ CheckAnchorAttempt(uid, component, args);
+ }
+
+ private void OnUnanchorAttempt(EntityUid uid, NukeComponent component, UnanchorAttemptEvent args)
+ {
+ CheckAnchorAttempt(uid, component, args);
+ }
+
+ private void CheckAnchorAttempt(EntityUid uid, NukeComponent component, BaseAnchoredAttemptEvent args)
+ {
+ // cancel any anchor attempt without nuke disk
+ if (!component.DiskInserted)
+ {
+ var msg = Loc.GetString("nuke-component-cant-anchor");
+ _popups.PopupEntity(msg, uid, Filter.Entities(args.User));
+
+ args.Cancel();
+ }
+ }
+
+ private void OnWasUnanchored(EntityUid uid, NukeComponent component, UnanchoredEvent args)
+ {
+ UpdateUserInterface(uid, component);
+ }
+
+ private void OnWasAnchored(EntityUid uid, NukeComponent component, AnchoredEvent args)
+ {
+ UpdateUserInterface(uid, component);
+ }
+ #endregion
+
+ #region UI Events
+ private void OnEjectButtonPressed(EntityUid uid, NukeComponent component, NukeEjectMessage args)
+ {
+ if (!component.DiskInserted)
+ return;
+
+ _itemSlots.TryEjectContent(uid, component.DiskSlotName, args.Session.AttachedEntity);
+ }
+
+ private async void OnAnchorButtonPressed(EntityUid uid, NukeComponent component, NukeAnchorMessage args)
+ {
+ if (!component.DiskInserted)
+ return;
+
+ if (!EntityManager.TryGetComponent(uid, out TransformComponent? transform))
+ return;
+
+ // manually set transform anchor (bypassing anchorable)
+ // todo: it will break pullable system
+ transform.Coordinates = transform.Coordinates.SnapToGrid();
+ transform.Anchored = !transform.Anchored;
+
+ UpdateUserInterface(uid, component);
+ }
+
+ private void OnEnterButtonPressed(EntityUid uid, NukeComponent component, NukeKeypadEnterMessage args)
+ {
+ if (component.Status != NukeStatus.AWAIT_CODE)
+ return;
+
+ UpdateStatus(uid, component);
+ UpdateUserInterface(uid, component);
+ }
+
+ private void OnKeypadButtonPressed(EntityUid uid, NukeComponent component, NukeKeypadMessage args)
+ {
+ PlaydSound(uid, component.KeypadPressSound, 0.125f, component);
+
+ if (component.Status != NukeStatus.AWAIT_CODE)
+ return;
+
+ if (component.EnteredCode.Length >= _codes.Code.Length)
+ return;
+
+ component.EnteredCode += args.Value.ToString();
+ UpdateUserInterface(uid, component);
+ }
+
+ private void OnClearButtonPressed(EntityUid uid, NukeComponent component, NukeKeypadClearMessage args)
+ {
+ PlaydSound(uid, component.KeypadPressSound, 0f, component);
+
+ if (component.Status != NukeStatus.AWAIT_CODE)
+ return;
+
+ component.EnteredCode = "";
+ UpdateUserInterface(uid, component);
+ }
+
+ private void OnArmButtonPressed(EntityUid uid, NukeComponent component, NukeArmedMessage args)
+ {
+ if (!component.DiskInserted)
+ return;
+
+ if (component.Status == NukeStatus.AWAIT_ARM)
+ {
+ ArmBomb(uid, component);
+ }
+ else
+ {
+ DisarmBomb(uid, component);
+ }
+ }
+ #endregion
+
+ private void UpdateStatus(EntityUid uid, NukeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ switch (component.Status)
+ {
+ case NukeStatus.AWAIT_DISK:
+ if (component.DiskInserted)
+ component.Status = NukeStatus.AWAIT_CODE;
+ break;
+ case NukeStatus.AWAIT_CODE:
+ {
+ if (!component.DiskInserted)
+ {
+ component.Status = NukeStatus.AWAIT_DISK;
+ component.EnteredCode = "";
+ break;
+ }
+
+ var isValid = _codes.IsCodeValid(component.EnteredCode);
+ if (isValid)
+ {
+ component.Status = NukeStatus.AWAIT_ARM;
+ component.RemainingTime = component.Timer;
+ PlaydSound(uid, component.AccessGrantedSound, 0, component);
+ }
+ else
+ {
+ component.EnteredCode = "";
+ PlaydSound(uid, component.AccessDeniedSound, 0, component);
+ }
+ break;
+ }
+ case NukeStatus.AWAIT_ARM:
+ // do nothing, wait for arm button to be pressed
+ break;
+ case NukeStatus.ARMED:
+ // do nothing, wait for arm button to be unpressed
+ break;
+ }
+ }
+
+ private void ShowUI(EntityUid uid, IPlayerSession session, NukeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var ui = component.Owner.GetUIOrNull(NukeUiKey.Key);
+ ui?.Open(session);
+
+ UpdateUserInterface(uid, component);
+ }
+
+ private void UpdateUserInterface(EntityUid uid, NukeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var ui = component.Owner.GetUIOrNull(NukeUiKey.Key);
+ if (ui == null)
+ return;
+
+ var anchored = false;
+ if (EntityManager.TryGetComponent(uid, out TransformComponent transform))
+ anchored = transform.Anchored;
+
+ var allowArm = component.DiskInserted &&
+ (component.Status == NukeStatus.AWAIT_ARM ||
+ component.Status == NukeStatus.ARMED);
+
+ var state = new NukeUiState()
+ {
+ Status = component.Status,
+ RemainingTime = (int) component.RemainingTime,
+ DiskInserted = component.DiskInserted,
+ IsAnchored = anchored,
+ AllowArm = allowArm,
+ EnteredCodeLength = component.EnteredCode.Length,
+ MaxCodeLength = _codes.Code.Length
+ };
+
+ ui.SetState(state);
+ }
+
+ private void PlaydSound(EntityUid uid, SoundSpecifier sound, float varyPitch = 0f,
+ NukeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ SoundSystem.Play(Filter.Pvs(uid), sound.GetSound(),
+ uid, AudioHelpers.WithVariation(varyPitch));
+ }
+
+ #region Public API
+ ///
+ /// Force a nuclear bomb to start a countdown timer
+ ///
+ public void ArmBomb(EntityUid uid, NukeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (component.Status == NukeStatus.ARMED)
+ return;
+
+ // warn a crew
+ var announcement = Loc.GetString("nuke-component-announcement-armed",
+ ("time", (int) component.RemainingTime));
+ var sender = Loc.GetString("nuke-component-announcement-sender");
+ _chat.DispatchStationAnnouncement(announcement, sender);
+
+ // todo: move it to announcements system
+ SoundSystem.Play(Filter.Broadcast(), component.ArmSound.GetSound());
+
+ component.Status = NukeStatus.ARMED;
+ _tickingBombs.Add(uid);
+ UpdateUserInterface(uid, component);
+ }
+
+ ///
+ /// Stop nuclear bomb timer
+ ///
+ public void DisarmBomb(EntityUid uid, NukeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (component.Status != NukeStatus.ARMED)
+ return;
+
+ // warn a crew
+ var announcement = Loc.GetString("nuke-component-announcement-unarmed");
+ var sender = Loc.GetString("nuke-component-announcement-sender");
+ _chat.DispatchStationAnnouncement(announcement, sender);
+
+ // todo: move it to announcements system
+ SoundSystem.Play(Filter.Broadcast(), component.DisarmSound.GetSound());
+
+ // disable sound and reset it
+ component.PlayedAlertSound = false;
+ component.AlertAudioStream?.Stop();
+
+ component.Status = NukeStatus.AWAIT_ARM;
+ _tickingBombs.Remove(uid);
+ UpdateUserInterface(uid, component);
+ }
+
+ ///
+ /// Toggle bomb arm button
+ ///
+ public void ToggleBomb(EntityUid uid, NukeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (component.Status == NukeStatus.ARMED)
+ DisarmBomb(uid, component);
+ else
+ ArmBomb(uid, component);
+ }
+
+ ///
+ /// Force bomb to explode immediately
+ ///
+ public void ActivateBomb(EntityUid uid, NukeComponent? component = null,
+ TransformComponent? transform = null)
+ {
+ if (!Resolve(uid, ref component, ref transform))
+ return;
+
+ // gib anyone in a blast radius
+ // its lame, but will work for now
+ var pos = transform.Coordinates;
+ var ents = _lookup.GetEntitiesInRange(pos, component.BlastRadius);
+ foreach (var ent in ents)
+ {
+ var entUid = ent.Uid;
+ if (!EntityManager.EntityExists(entUid))
+ continue;;
+
+ if (EntityManager.TryGetComponent(entUid, out SharedBodyComponent? body))
+ body.Gib();
+ }
+
+ EntityManager.DeleteEntity(uid);
+ }
+
+ ///
+ /// Set remaining time value
+ ///
+ public void SetRemainingTime(EntityUid uid, float timer, NukeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ component.RemainingTime = timer;
+ UpdateUserInterface(uid, component);
+ }
+ #endregion
+ }
+}
diff --git a/Content.Server/Paper/PaperComponent.cs b/Content.Server/Paper/PaperComponent.cs
index 7c37957dde..35729d9c9b 100644
--- a/Content.Server/Paper/PaperComponent.cs
+++ b/Content.Server/Paper/PaperComponent.cs
@@ -20,7 +20,7 @@ namespace Content.Server.Paper
{
private PaperAction _mode;
[DataField("content")]
- public string Content { get; private set; } = "";
+ public string Content { get; set; } = "";
[ViewVariables] private BoundUserInterface? UserInterface => Owner.GetUIOrNull(PaperUiKey.Key);
diff --git a/Content.Shared/Nuke/NukeUiMessages.cs b/Content.Shared/Nuke/NukeUiMessages.cs
new file mode 100644
index 0000000000..94f66450d5
--- /dev/null
+++ b/Content.Shared/Nuke/NukeUiMessages.cs
@@ -0,0 +1,42 @@
+using System;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Nuke
+{
+ [Serializable, NetSerializable]
+ public sealed class NukeEjectMessage : BoundUserInterfaceMessage
+ {
+ }
+
+ [Serializable, NetSerializable]
+ public sealed class NukeAnchorMessage : BoundUserInterfaceMessage
+ {
+ }
+
+ [Serializable, NetSerializable]
+ public sealed class NukeKeypadMessage : BoundUserInterfaceMessage
+ {
+ public int Value;
+
+ public NukeKeypadMessage(int value)
+ {
+ Value = value;
+ }
+ }
+
+ [Serializable, NetSerializable]
+ public sealed class NukeKeypadClearMessage : BoundUserInterfaceMessage
+ {
+ }
+
+ [Serializable, NetSerializable]
+ public sealed class NukeKeypadEnterMessage : BoundUserInterfaceMessage
+ {
+ }
+
+ [Serializable, NetSerializable]
+ public sealed class NukeArmedMessage : BoundUserInterfaceMessage
+ {
+ }
+}
diff --git a/Content.Shared/Nuke/SharedNuke.cs b/Content.Shared/Nuke/SharedNuke.cs
new file mode 100644
index 0000000000..a440aaeeb9
--- /dev/null
+++ b/Content.Shared/Nuke/SharedNuke.cs
@@ -0,0 +1,32 @@
+using System;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Nuke
+{
+ [Serializable, NetSerializable]
+ public enum NukeUiKey : byte
+ {
+ Key
+ }
+
+ public enum NukeStatus : byte
+ {
+ AWAIT_DISK,
+ AWAIT_CODE,
+ AWAIT_ARM,
+ ARMED
+ }
+
+ [Serializable, NetSerializable]
+ public class NukeUiState : BoundUserInterfaceState
+ {
+ public bool DiskInserted;
+ public NukeStatus Status;
+ public int RemainingTime;
+ public bool IsAnchored;
+ public int EnteredCodeLength;
+ public int MaxCodeLength;
+ public bool AllowArm;
+ }
+}
diff --git a/Resources/Audio/Machines/Nuke/angry_beep.ogg b/Resources/Audio/Machines/Nuke/angry_beep.ogg
new file mode 100644
index 0000000000..547779de1b
Binary files /dev/null and b/Resources/Audio/Machines/Nuke/angry_beep.ogg differ
diff --git a/Resources/Audio/Machines/Nuke/confirm_beep.ogg b/Resources/Audio/Machines/Nuke/confirm_beep.ogg
new file mode 100644
index 0000000000..6b98ba8bd6
Binary files /dev/null and b/Resources/Audio/Machines/Nuke/confirm_beep.ogg differ
diff --git a/Resources/Audio/Machines/Nuke/general_beep.ogg b/Resources/Audio/Machines/Nuke/general_beep.ogg
new file mode 100644
index 0000000000..c149eb300a
Binary files /dev/null and b/Resources/Audio/Machines/Nuke/general_beep.ogg differ
diff --git a/Resources/Audio/Machines/alarm.ogg b/Resources/Audio/Machines/alarm.ogg
new file mode 100644
index 0000000000..2aec35bd32
Binary files /dev/null and b/Resources/Audio/Machines/alarm.ogg differ
diff --git a/Resources/Audio/Machines/terminal_insert_disc.ogg b/Resources/Audio/Machines/terminal_insert_disc.ogg
new file mode 100644
index 0000000000..dd226c1ebd
Binary files /dev/null and b/Resources/Audio/Machines/terminal_insert_disc.ogg differ
diff --git a/Resources/Audio/Misc/notice1.ogg b/Resources/Audio/Misc/notice1.ogg
new file mode 100644
index 0000000000..da6454ce3c
Binary files /dev/null and b/Resources/Audio/Misc/notice1.ogg differ
diff --git a/Resources/Audio/Misc/notice2.ogg b/Resources/Audio/Misc/notice2.ogg
new file mode 100644
index 0000000000..3489ca3e15
Binary files /dev/null and b/Resources/Audio/Misc/notice2.ogg differ
diff --git a/Resources/Locale/en-US/nuke/nuke-component.ftl b/Resources/Locale/en-US/nuke/nuke-component.ftl
new file mode 100644
index 0000000000..2b0d35b7ca
--- /dev/null
+++ b/Resources/Locale/en-US/nuke/nuke-component.ftl
@@ -0,0 +1,27 @@
+nuke-component-cant-anchor = The bolts seems to be blocked without disk!
+nuke-component-announcement-sender = Nuclear Fission Explosive
+nuke-component-announcement-armed = Attention! The station's self-destruct mechanism has been engaged. {$time} seconds until detonation.
+nuke-component-announcement-unarmed = The station's self-destruct was deactivated! Have a nice day!
+nuke-component-announcement-send-codes = Attention! Requested self-destruction codes was sent to communication consoles.
+
+# Nuke UI
+nuke-user-interface-title = Nuclear Fission Explosive
+nuke-user-interface-arm-button = ARM
+nuke-user-interface-anchor-button = ANCHOR
+nuke-user-interface-eject-button = EJECT
+
+## Upper status
+nuke-user-interface-first-status-device-locked = DEVICE LOCKED
+nuke-user-interface-first-status-input-code = INPUT CODE
+nuke-user-interface-first-status-input-time = INPUT TIME
+nuke-user-interface-first-status-device-ready = DEVICE READY
+nuke-user-interface-first-status-device-armed = DEVICE ARMED
+nuke-user-interface-status-error = ERROR
+
+## Lower status
+nuke-user-interface-second-status-await-disk = AWAIT DISK
+nuke-user-interface-second-status-time = TIME: {$time}
+nuke-user-interface-second-status-current-code = CODE: {$code}
+
+
+
diff --git a/Resources/Prototypes/Entities/Objects/Devices/nuke.yml b/Resources/Prototypes/Entities/Objects/Devices/nuke.yml
new file mode 100644
index 0000000000..850fb47532
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/nuke.yml
@@ -0,0 +1,40 @@
+- type: entity
+ parent: BaseStructureDynamic
+ id: NuclearBomb
+ name: nuclear fission explosive
+ description: You probably shouldn't stick around to see if this is armed.
+ components:
+ - type: Transform
+ anchored: true
+ - type: Sprite
+ sprite: Objects/Devices/nuke.rsi
+ netsync: false
+ state: nuclearbomb_base
+ - type: Physics
+ bodyType: Dynamic
+ fixtures:
+ - shape:
+ !type:PhysShapeCircle
+ radius: 0.45
+ mass: 150
+ layer:
+ - SmallImpassable
+ mask:
+ - VaultImpassable
+ - type: Nuke
+ - type: InteractionOutline
+ - type: ItemSlots
+ slots:
+ DiskSlot:
+ name: Disk
+ insertSound:
+ path: /Audio/Machines/terminal_insert_disc.ogg
+ ejectSound:
+ path: /Audio/Machines/terminal_insert_disc.ogg
+ whitelist:
+ tags:
+ - NukeDisk
+ - type: UserInterface
+ interfaces:
+ - key: enum.NukeUiKey.Key
+ type: NukeBoundUserInterface
diff --git a/Resources/Prototypes/Entities/Objects/Misc/paper.yml b/Resources/Prototypes/Entities/Objects/Misc/paper.yml
index 6b8eb3c850..deb1a2a251 100644
--- a/Resources/Prototypes/Entities/Objects/Misc/paper.yml
+++ b/Resources/Prototypes/Entities/Objects/Misc/paper.yml
@@ -36,6 +36,17 @@
# something happened, so that ought to override this either way.
- state: paper_words
+- type: entity
+ parent: PaperWritten
+ id: NukeCodePaper
+ name: nuclear authentication codes
+ components:
+ - type: NukeCodePaper
+ - type: Paper
+ content: |
+ [color=red]TOP SECRET![/color]
+ Nuclear device activation code:
+
- type: entity
name: pen
parent: BaseItem
diff --git a/Resources/Textures/Objects/Devices/nuke.rsi/meta.json b/Resources/Textures/Objects/Devices/nuke.rsi/meta.json
new file mode 100644
index 0000000000..169795659b
--- /dev/null
+++ b/Resources/Textures/Objects/Devices/nuke.rsi/meta.json
@@ -0,0 +1,23 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/59f2a4e10e5ba36033c9734ddebfbbdc6157472d",
+ "states": [
+ {
+ "name": "nuclearbomb_base"
+ },
+ {
+ "name": "nuclearbomb_exploding"
+ },
+ {
+ "name": "nuclearbomb_safetyoff"
+ },
+ {
+ "name": "nuclearbomb_timing"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_base.png b/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_base.png
new file mode 100644
index 0000000000..cc1da90991
Binary files /dev/null and b/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_base.png differ
diff --git a/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_exploding.png b/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_exploding.png
new file mode 100644
index 0000000000..937d431579
Binary files /dev/null and b/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_exploding.png differ
diff --git a/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_safetyoff.png b/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_safetyoff.png
new file mode 100644
index 0000000000..a28fb828c2
Binary files /dev/null and b/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_safetyoff.png differ
diff --git a/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_timing.png b/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_timing.png
new file mode 100644
index 0000000000..d8c47c2d03
Binary files /dev/null and b/Resources/Textures/Objects/Devices/nuke.rsi/nuclearbomb_timing.png differ