diff --git a/Content.Client/Fax/UI/FaxBoundUi.cs b/Content.Client/Fax/UI/FaxBoundUi.cs
new file mode 100644
index 0000000000..f6d31e78db
--- /dev/null
+++ b/Content.Client/Fax/UI/FaxBoundUi.cs
@@ -0,0 +1,58 @@
+using Content.Shared.Fax;
+using Robust.Client.GameObjects;
+
+namespace Content.Client.Fax.UI;
+
+public sealed class FaxBoundUi : BoundUserInterface
+{
+ private FaxWindow? _window;
+
+ public FaxBoundUi(ClientUserInterfaceComponent owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = new FaxWindow();
+ _window.OpenCentered();
+
+ _window.OnClose += Close;
+ _window.SendButtonPressed += OnSendButtonPressed;
+ _window.RefreshButtonPressed += OnRefreshButtonPressed;
+ _window.PeerSelected += OnPeerSelected;
+ }
+
+ private void OnSendButtonPressed()
+ {
+ SendMessage(new FaxSendMessage());
+ }
+
+ private void OnRefreshButtonPressed()
+ {
+ SendMessage(new FaxRefreshMessage());
+ }
+
+ private void OnPeerSelected(string address)
+ {
+ SendMessage(new FaxDestinationMessage(address));
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (_window == null || state is not FaxUiState cast)
+ return;
+
+ _window.UpdateState(cast);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ _window?.Dispose();
+ }
+}
diff --git a/Content.Client/Fax/UI/FaxWindow.xaml b/Content.Client/Fax/UI/FaxWindow.xaml
new file mode 100644
index 0000000000..c83e617aa7
--- /dev/null
+++ b/Content.Client/Fax/UI/FaxWindow.xaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Content.Client/Fax/UI/FaxWindow.xaml.cs b/Content.Client/Fax/UI/FaxWindow.xaml.cs
new file mode 100644
index 0000000000..56c53d162c
--- /dev/null
+++ b/Content.Client/Fax/UI/FaxWindow.xaml.cs
@@ -0,0 +1,81 @@
+using System.Linq;
+using Content.Shared.Fax;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Fax.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class FaxWindow : DefaultWindow
+{
+ public event Action? SendButtonPressed;
+ public event Action? RefreshButtonPressed;
+ public event Action? PeerSelected;
+
+ public FaxWindow()
+ {
+ RobustXamlLoader.Load(this);
+
+ SendButton.OnPressed += _ => SendButtonPressed?.Invoke();
+ RefreshButton.OnPressed += _ => RefreshButtonPressed?.Invoke();
+ PeerSelector.OnItemSelected += args =>
+ PeerSelected?.Invoke((string) args.Button.GetItemMetadata(args.Id)!);
+ }
+
+ public void UpdateState(FaxUiState state)
+ {
+ SendButton.Disabled = !state.CanSend;
+ FromLabel.Text = state.DeviceName;
+
+ if (state.IsPaperInserted)
+ {
+ PaperStatusLabel.FontColorOverride = Color.Green;
+ PaperStatusLabel.Text = Loc.GetString("fax-machine-ui-paper-inserted");
+ }
+ else
+ {
+ PaperStatusLabel.FontColorOverride = Color.Red;
+ PaperStatusLabel.Text = Loc.GetString("fax-machine-ui-paper-not-inserted");
+ }
+
+ if (state.AvailablePeers.Count == 0)
+ {
+ PeerSelector.AddItem(Loc.GetString("fax-machine-ui-no-peers"));
+ PeerSelector.Disabled = true;
+ }
+
+ if (PeerSelector.Disabled && state.AvailablePeers.Count != 0)
+ {
+ PeerSelector.Clear();
+ PeerSelector.Disabled = false;
+ }
+
+ // always must be selected destination
+ if (string.IsNullOrEmpty(state.DestinationAddress) && state.AvailablePeers.Count != 0)
+ {
+ PeerSelected?.Invoke(state.AvailablePeers.First().Key);
+ return;
+ }
+
+ if (state.AvailablePeers.Count != 0)
+ {
+ PeerSelector.Clear();
+
+ foreach (var (address, name) in state.AvailablePeers)
+ {
+ var id = AddPeerSelect(name, address);
+ if (address == state.DestinationAddress)
+ PeerSelector.Select(id);
+ }
+ }
+ }
+
+ private int AddPeerSelect(string name, string address)
+ {
+ PeerSelector.AddItem(name);
+ PeerSelector.SetItemMetadata(PeerSelector.ItemCount - 1, address);
+ return PeerSelector.ItemCount - 1;
+ }
+}
diff --git a/Content.Server/Fax/FaxConstants.cs b/Content.Server/Fax/FaxConstants.cs
new file mode 100644
index 0000000000..d8b3aea6db
--- /dev/null
+++ b/Content.Server/Fax/FaxConstants.cs
@@ -0,0 +1,28 @@
+namespace Content.Server.Fax;
+
+public static class FaxConstants
+{
+ // Commands
+
+ /**
+ * Used to get other faxes connected to current network
+ */
+ public const string FaxPingCommand = "fax_ping";
+
+ /**
+ * Used as response to ping command
+ */
+ public const string FaxPongCommand = "fax_pong";
+
+ /**
+ * Used when fax sending data to destination fax
+ */
+ public const string FaxPrintCommand = "fax_print";
+
+ // Data
+
+ public const string FaxNameData = "fax_data_name";
+ public const string FaxPaperNameData = "fax_data_title";
+ public const string FaxPaperContentData = "fax_data_content";
+ public const string FaxSyndicateData = "fax_data_i_am_syndicate";
+}
diff --git a/Content.Server/Fax/FaxMachineComponent.cs b/Content.Server/Fax/FaxMachineComponent.cs
new file mode 100644
index 0000000000..2b6e7e053e
--- /dev/null
+++ b/Content.Server/Fax/FaxMachineComponent.cs
@@ -0,0 +1,142 @@
+using Content.Shared.Containers.ItemSlots;
+using Robust.Shared.Audio;
+
+namespace Content.Server.Fax;
+
+[RegisterComponent]
+public sealed class FaxMachineComponent : Component
+{
+ ///
+ /// Name with which the fax will be visible to others on the network
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("name")]
+ public string FaxName { get; set; } = "Unknown";
+
+ ///
+ /// Device address of fax in network to which data will be send
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("destinationAddress")]
+ public string? DestinationFaxAddress { get; set; }
+
+ ///
+ /// Contains the item to be sent, assumes it's paper...
+ ///
+ [DataField("paperSlot", required: true)]
+ public ItemSlot PaperSlot = new();
+
+ ///
+ /// Is fax machine should respond to pings in network
+ /// This will make it visible to others on the network
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("responsePings")]
+ public bool ResponsePings { get; set; } = true;
+
+ ///
+ /// Should admins be notified on message receive
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("notifyAdmins")]
+ public bool NotifyAdmins { get; set; } = false;
+
+ ///
+ /// Should that fax receive nuke codes send by admins. Probably should be captain fax only
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("receiveNukeCodes")]
+ public bool ReceiveNukeCodes { get; set; } = false;
+
+ ///
+ /// Is fax was emaaged
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("emagged")]
+ public bool Emagged { get; set; } = false;
+
+ ///
+ /// Sound to play when fax has been emagged
+ ///
+ [DataField("emagSound")]
+ public SoundSpecifier EmagSound = new SoundCollectionSpecifier("sparks");
+
+ ///
+ /// Sound to play when fax printing new message
+ ///
+ [DataField("printSound")]
+ public SoundSpecifier PrintSound = new SoundPathSpecifier("/Audio/Machines/printer.ogg");
+
+ ///
+ /// Sound to play when fax successfully send message
+ ///
+ [DataField("sendSound")]
+ public SoundSpecifier SendSound = new SoundPathSpecifier("/Audio/Machines/high_tech_confirm.ogg");
+
+ ///
+ /// Known faxes in network by address with fax names
+ ///
+ [ViewVariables]
+ public Dictionary KnownFaxes { get; } = new();
+
+ ///
+ /// Print queue of the incoming message
+ ///
+ [ViewVariables]
+ [DataField("printingQueue")]
+ public Queue PrintingQueue { get; } = new();
+
+ ///
+ /// Message sending timeout
+ ///
+ [ViewVariables]
+ [DataField("sendTimeoutRemaining")]
+ public float SendTimeoutRemaining;
+
+ ///
+ /// Message sending timeout
+ ///
+ [ViewVariables]
+ [DataField("sendTimeout")]
+ public float SendTimeout = 5f;
+
+ ///
+ /// Remaining time of inserting animation
+ ///
+ [DataField("insertingTimeRemaining")]
+ public float InsertingTimeRemaining;
+
+ ///
+ /// How long the inserting animation will play
+ ///
+ [ViewVariables]
+ public float InsertionTime = 1.88f; // 0.02 off for correct animation
+
+ ///
+ /// Remaining time of printing animation
+ ///
+ [DataField("printingTimeRemaining")]
+ public float PrintingTimeRemaining;
+
+ ///
+ /// How long the printing animation will play
+ ///
+ [ViewVariables]
+ public float PrintingTime = 2.3f;
+}
+
+[DataDefinition]
+public sealed class FaxPrintout
+{
+ [DataField("name")]
+ public string Name { get; }
+
+ [DataField("content")]
+ public string Content { get; }
+
+ public FaxPrintout(string content, string name)
+ {
+ Content = content;
+ Name = name;
+ }
+}
diff --git a/Content.Server/Fax/FaxSystem.cs b/Content.Server/Fax/FaxSystem.cs
new file mode 100644
index 0000000000..1b2275db84
--- /dev/null
+++ b/Content.Server/Fax/FaxSystem.cs
@@ -0,0 +1,442 @@
+using Content.Server.Administration;
+using Content.Server.Administration.Managers;
+using Content.Server.Chat.Managers;
+using Content.Server.DeviceNetwork;
+using Content.Server.DeviceNetwork.Components;
+using Content.Server.DeviceNetwork.Systems;
+using Content.Server.Paper;
+using Content.Server.Popups;
+using Content.Server.Power.Components;
+using Content.Server.Power.EntitySystems;
+using Content.Server.Tools;
+using Content.Server.UserInterface;
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.Emag.Systems;
+using Content.Shared.Fax;
+using Content.Shared.Interaction;
+using Robust.Server.GameObjects;
+using Robust.Shared.Containers;
+using Robust.Shared.Player;
+
+namespace Content.Server.Fax;
+
+public sealed class FaxSystem : EntitySystem
+{
+ [Dependency] private readonly IChatManager _chat = default!;
+ [Dependency] private readonly IAdminManager _adminManager = default!;
+ [Dependency] private readonly ItemSlotsSystem _itemSlotsSystem = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearanceSystem = default!;
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+ [Dependency] private readonly DeviceNetworkSystem _deviceNetworkSystem = default!;
+ [Dependency] private readonly PaperSystem _paperSystem = default!;
+ [Dependency] private readonly SharedAudioSystem _audioSystem = default!;
+ [Dependency] private readonly ToolSystem _toolSystem = default!;
+ [Dependency] private readonly QuickDialogSystem _quickDialog = default!;
+ [Dependency] private readonly UserInterfaceSystem _userInterface = default!;
+
+ public const string PaperSlotId = "Paper";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ // Hooks
+ SubscribeLocalEvent(OnComponentInit);
+ SubscribeLocalEvent(OnMapInit);
+ SubscribeLocalEvent(OnComponentRemove);
+
+ SubscribeLocalEvent(OnItemSlotChanged);
+ SubscribeLocalEvent(OnItemSlotChanged);
+ SubscribeLocalEvent(OnPowerChanged);
+ SubscribeLocalEvent(OnPacketReceived);
+
+ // Interaction
+ SubscribeLocalEvent(OnInteractUsing);
+ SubscribeLocalEvent(OnEmagged);
+
+ // UI
+ SubscribeLocalEvent(OnToggleInterface);
+ SubscribeLocalEvent(OnSendButtonPressed);
+ SubscribeLocalEvent(OnRefreshButtonPressed);
+ SubscribeLocalEvent(OnDestinationSelected);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ foreach (var (comp, receiver) in EntityQuery())
+ {
+ if (!receiver.Powered)
+ continue;
+
+ ProcessPrintingAnimation(frameTime, comp);
+ ProcessInsertingAnimation(frameTime, comp);
+ ProcessSendingTimeout(frameTime, comp);
+ }
+ }
+
+ private void ProcessPrintingAnimation(float frameTime, FaxMachineComponent comp)
+ {
+ if (comp.PrintingTimeRemaining > 0)
+ {
+ comp.PrintingTimeRemaining -= frameTime;
+ UpdateAppearance(comp.Owner, comp);
+
+ var isAnimationEnd = comp.PrintingTimeRemaining <= 0;
+ if (isAnimationEnd)
+ {
+ SpawnPaperFromQueue(comp.Owner, comp);
+ UpdateUserInterface(comp.Owner, comp);
+ }
+
+ return;
+ }
+
+ if (comp.PrintingQueue.Count > 0)
+ {
+ comp.PrintingTimeRemaining = comp.PrintingTime;
+ _audioSystem.PlayPvs(comp.PrintSound, comp.Owner);
+ }
+ }
+
+ private void ProcessInsertingAnimation(float frameTime, FaxMachineComponent comp)
+ {
+ if (comp.InsertingTimeRemaining <= 0)
+ return;
+
+ comp.InsertingTimeRemaining -= frameTime;
+ UpdateAppearance(comp.Owner, comp);
+
+ var isAnimationEnd = comp.InsertingTimeRemaining <= 0;
+ if (isAnimationEnd)
+ {
+ _itemSlotsSystem.SetLock(comp.Owner, comp.PaperSlot, false);
+ UpdateUserInterface(comp.Owner, comp);
+ }
+ }
+
+ private void ProcessSendingTimeout(float frameTime, FaxMachineComponent comp)
+ {
+ if (comp.SendTimeoutRemaining > 0)
+ {
+ comp.SendTimeoutRemaining -= frameTime;
+
+ if (comp.SendTimeoutRemaining <= 0)
+ UpdateUserInterface(comp.Owner, comp);
+ }
+ }
+
+ private void OnComponentInit(EntityUid uid, FaxMachineComponent component, ComponentInit args)
+ {
+ _itemSlotsSystem.AddItemSlot(uid, FaxSystem.PaperSlotId, component.PaperSlot);
+ UpdateAppearance(uid, component);
+ }
+
+ private void OnComponentRemove(EntityUid uid, FaxMachineComponent component, ComponentRemove args)
+ {
+ _itemSlotsSystem.RemoveItemSlot(uid, component.PaperSlot);
+ }
+
+ private void OnMapInit(EntityUid uid, FaxMachineComponent component, MapInitEvent args)
+ {
+ // Load all faxes on map in cache each other to prevent taking same name by user created fax
+ Refresh(uid, component);
+ }
+
+ private void OnItemSlotChanged(EntityUid uid, FaxMachineComponent component, ContainerModifiedMessage args)
+ {
+ if (!component.Initialized)
+ return;
+
+ if (args.Container.ID != component.PaperSlot.ID)
+ return;
+
+ var isPaperInserted = component.PaperSlot.Item.HasValue;
+ if (isPaperInserted)
+ {
+ component.InsertingTimeRemaining = component.InsertionTime;
+ _itemSlotsSystem.SetLock(uid, component.PaperSlot, true);
+ }
+
+ UpdateUserInterface(uid, component);
+ }
+
+ private void OnPowerChanged(EntityUid uid, FaxMachineComponent component, ref PowerChangedEvent args)
+ {
+ var isInsertInterrupted = !args.Powered && component.InsertingTimeRemaining > 0;
+ if (isInsertInterrupted)
+ {
+ component.InsertingTimeRemaining = 0f; // Reset animation
+
+ // Drop from slot because animation did not play completely
+ _itemSlotsSystem.SetLock(uid, component.PaperSlot, false);
+ _itemSlotsSystem.TryEject(uid, component.PaperSlot, null, out var _, true);
+ }
+
+ var isPrintInterrupted = !args.Powered && component.PrintingTimeRemaining > 0;
+ if (isPrintInterrupted)
+ {
+ component.PrintingTimeRemaining = 0f; // Reset animation
+ }
+
+ if (isInsertInterrupted || isPrintInterrupted)
+ UpdateAppearance(component.Owner, component);
+
+ _itemSlotsSystem.SetLock(uid, component.PaperSlot, !args.Powered); // Lock slot when power is off
+ }
+
+ private void OnInteractUsing(EntityUid uid, FaxMachineComponent component, InteractUsingEvent args)
+ {
+ if (args.Handled ||
+ !TryComp(args.User, out var actor) ||
+ !_toolSystem.HasQuality(args.Used, "Screwing")) // Screwing because Pulsing already used by device linking
+ return;
+
+ _quickDialog.OpenDialog(actor.PlayerSession,
+ Loc.GetString("fax-machine-dialog-rename"),
+ Loc.GetString("fax-machine-dialog-field-name"),
+ (string newName) =>
+ {
+ if (component.FaxName == newName)
+ return;
+
+ if (newName.Length > 20)
+ {
+ _popupSystem.PopupEntity(Loc.GetString("fax-machine-popup-name-long"), uid, Filter.Pvs(uid));
+ return;
+ }
+
+ if (component.KnownFaxes.ContainsValue(newName) && !component.Emagged) // Allow exist names if emagged for fun
+ {
+ _popupSystem.PopupEntity(Loc.GetString("fax-machine-popup-name-exist"), uid, Filter.Pvs(uid));
+ return;
+ }
+
+ component.FaxName = newName;
+ _popupSystem.PopupEntity(Loc.GetString("fax-machine-popup-name-set"), uid, Filter.Pvs(uid));
+ UpdateUserInterface(uid, component);
+ });
+
+ args.Handled = true;
+ }
+
+ private void OnEmagged(EntityUid uid, FaxMachineComponent component, GotEmaggedEvent args)
+ {
+ if (component.Emagged)
+ return;
+
+ _audioSystem.PlayPvs(component.EmagSound, uid);
+ component.Emagged = true;
+ args.Handled = true;
+ }
+
+ private void OnPacketReceived(EntityUid uid, FaxMachineComponent component, DeviceNetworkPacketEvent args)
+ {
+ if (!HasComp(uid) || string.IsNullOrEmpty(args.SenderAddress))
+ return;
+
+ if (args.Data.TryGetValue(DeviceNetworkConstants.Command, out string? command))
+ {
+ switch (command)
+ {
+ case FaxConstants.FaxPingCommand:
+ var isForSyndie = component.Emagged &&
+ args.Data.ContainsKey(FaxConstants.FaxSyndicateData);
+ if (!isForSyndie && !component.ResponsePings)
+ return;
+
+ var payload = new NetworkPayload()
+ {
+ { DeviceNetworkConstants.Command, FaxConstants.FaxPongCommand },
+ { FaxConstants.FaxNameData, component.FaxName }
+ };
+ _deviceNetworkSystem.QueuePacket(uid, args.SenderAddress, payload);
+
+ break;
+ case FaxConstants.FaxPongCommand:
+ if (!args.Data.TryGetValue(FaxConstants.FaxNameData, out string? faxName))
+ return;
+
+ component.KnownFaxes[args.SenderAddress] = faxName;
+
+ UpdateUserInterface(uid, component);
+
+ break;
+ case FaxConstants.FaxPrintCommand:
+ if (!args.Data.TryGetValue(FaxConstants.FaxPaperNameData, out string? name) ||
+ !args.Data.TryGetValue(FaxConstants.FaxPaperContentData, out string? content))
+ return;
+
+ var printout = new FaxPrintout(content, name);
+ Receive(uid, printout, args.SenderAddress);
+
+ break;
+ }
+ }
+ }
+
+ private void OnToggleInterface(EntityUid uid, FaxMachineComponent component, AfterActivatableUIOpenEvent args)
+ {
+ UpdateUserInterface(uid, component);
+ }
+
+ private void OnSendButtonPressed(EntityUid uid, FaxMachineComponent component, FaxSendMessage args)
+ {
+ Send(uid, component);
+ }
+
+ private void OnRefreshButtonPressed(EntityUid uid, FaxMachineComponent component, FaxRefreshMessage args)
+ {
+ Refresh(uid, component);
+ }
+
+ private void OnDestinationSelected(EntityUid uid, FaxMachineComponent component, FaxDestinationMessage args)
+ {
+ SetDestination(uid, args.Address, component);
+ }
+
+ private void UpdateAppearance(EntityUid uid, FaxMachineComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (component.InsertingTimeRemaining > 0)
+ _appearanceSystem.SetData(uid, FaxMachineVisuals.VisualState, FaxMachineVisualState.Inserting);
+ else if (component.PrintingTimeRemaining > 0)
+ _appearanceSystem.SetData(uid, FaxMachineVisuals.VisualState, FaxMachineVisualState.Printing);
+ else
+ _appearanceSystem.SetData(uid, FaxMachineVisuals.VisualState, FaxMachineVisualState.Normal);
+ }
+
+ private void UpdateUserInterface(EntityUid uid, FaxMachineComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var isPaperInserted = component.PaperSlot.Item != null;
+ var canSend = isPaperInserted &&
+ component.DestinationFaxAddress != null &&
+ component.SendTimeoutRemaining <= 0 &&
+ component.InsertingTimeRemaining <= 0;
+ var state = new FaxUiState(component.FaxName, component.KnownFaxes, canSend, isPaperInserted, component.DestinationFaxAddress);
+ _userInterface.TrySetUiState(uid, FaxUiKey.Key, state);
+ }
+
+ ///
+ /// Set fax destination address not checking if he knows it exists
+ ///
+ public void SetDestination(EntityUid uid, string destAddress, FaxMachineComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ component.DestinationFaxAddress = destAddress;
+
+ UpdateUserInterface(uid, component);
+ }
+
+ ///
+ /// Clears current known fax info and make network scan ping
+ /// Adds special data to payload if it was emagged to identify itself as a Syndicate
+ ///
+ public void Refresh(EntityUid uid, FaxMachineComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ component.DestinationFaxAddress = null;
+ component.KnownFaxes.Clear();
+
+ var payload = new NetworkPayload()
+ {
+ { DeviceNetworkConstants.Command, FaxConstants.FaxPingCommand }
+ };
+
+ if (component.Emagged)
+ payload.Add(FaxConstants.FaxSyndicateData, true);
+
+ _deviceNetworkSystem.QueuePacket(uid, null, payload);
+ }
+
+ ///
+ /// Sends message to addressee if paper is set and a known fax is selected
+ /// A timeout is set after sending
+ ///
+ public void Send(EntityUid uid, FaxMachineComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var sendEntity = component.PaperSlot.Item;
+ if (sendEntity == null)
+ return;
+
+ if (component.DestinationFaxAddress == null)
+ return;
+
+ if (!component.KnownFaxes.TryGetValue(component.DestinationFaxAddress, out var faxName))
+ return;
+
+ if (!TryComp(sendEntity, out var metadata) ||
+ !TryComp(sendEntity, out var paper))
+ return;
+
+ var payload = new NetworkPayload()
+ {
+ { DeviceNetworkConstants.Command, FaxConstants.FaxPrintCommand },
+ { FaxConstants.FaxPaperNameData, metadata.EntityName },
+ { FaxConstants.FaxPaperContentData, paper.Content },
+ };
+ _deviceNetworkSystem.QueuePacket(uid, component.DestinationFaxAddress, payload);
+
+ component.SendTimeoutRemaining += component.SendTimeout;
+
+ _audioSystem.PlayPvs(component.SendSound, uid);
+
+ UpdateUserInterface(uid, component);
+ }
+
+ ///
+ /// Accepts a new message and adds it to the queue to print
+ /// If has parameter "notifyAdmins" also output a special message to admin chat.
+ ///
+ public void Receive(EntityUid uid, FaxPrintout printout, string? fromAddress, FaxMachineComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var faxName = Loc.GetString("fax-machine-popup-source-unknown");
+ if (fromAddress != null && component.KnownFaxes.ContainsKey(fromAddress)) // If message received from unknown for fax address
+ faxName = component.KnownFaxes[fromAddress];
+
+ _popupSystem.PopupEntity(Loc.GetString("fax-machine-popup-received", ("from", faxName)), uid, Filter.Pvs(uid));
+ _appearanceSystem.SetData(uid, FaxMachineVisuals.VisualState, FaxMachineVisualState.Printing);
+
+ if (component.NotifyAdmins)
+ NotifyAdmins(faxName);
+
+ component.PrintingQueue.Enqueue(printout);
+ }
+
+ private void SpawnPaperFromQueue(EntityUid uid, FaxMachineComponent? component = null)
+ {
+ if (!Resolve(uid, ref component) || component.PrintingQueue.Count == 0)
+ return;
+
+ var printout = component.PrintingQueue.Dequeue();
+ var printed = EntityManager.SpawnEntity("Paper", Transform(uid).Coordinates);
+
+ if (TryComp(printed, out var paper))
+ _paperSystem.SetContent(printed, printout.Content);
+
+ if (TryComp(printed, out var metadata))
+ metadata.EntityName = printout.Name;
+ }
+
+ private void NotifyAdmins(string faxName)
+ {
+ _chat.SendAdminAnnouncement(Loc.GetString("fax-machine-chat-notify", ("fax", faxName)));
+ _audioSystem.PlayGlobal("/Audio/Machines/high_tech_confirm.ogg", Filter.Empty().AddPlayers(_adminManager.ActiveAdmins), false);
+ }
+}
diff --git a/Content.Server/Nuke/NukeCodePaperSystem.cs b/Content.Server/Nuke/NukeCodePaperSystem.cs
index d2df5cdf57..4965018916 100644
--- a/Content.Server/Nuke/NukeCodePaperSystem.cs
+++ b/Content.Server/Nuke/NukeCodePaperSystem.cs
@@ -1,5 +1,7 @@
+using System.Diagnostics.CodeAnalysis;
using Content.Server.Chat.Systems;
using Content.Server.Communications;
+using Content.Server.Fax;
using Content.Server.Paper;
using Content.Server.Station.Components;
using Content.Server.Station.Systems;
@@ -10,8 +12,8 @@ namespace Content.Server.Nuke
{
[Dependency] private readonly ChatSystem _chatSystem = default!;
[Dependency] private readonly StationSystem _station = default!;
-
- private const string NukePaperPrototype = "NukeCodePaper";
+ [Dependency] private readonly PaperSystem _paper = default!;
+ [Dependency] private readonly FaxSystem _faxSystem = default!;
public override void Initialize()
{
@@ -25,34 +27,18 @@ namespace Content.Server.Nuke
SetupPaper(uid);
}
- private void SetupPaper(EntityUid uid, EntityUid? station = null, PaperComponent? paper = null)
+ private void SetupPaper(EntityUid uid, EntityUid? station = null)
{
- if (!Resolve(uid, ref paper))
+ if (TryGetRelativeNukeCode(uid, out var paperContent, station))
{
- return;
- }
-
- var owningStation = station ?? _station.GetOwningStation(uid);
- var transform = Transform(uid);
-
- // Find the first nuke that matches the paper's location.
- foreach (var nuke in EntityQuery())
- {
- if (owningStation == null && nuke.OriginMapGrid != (transform.MapID, transform.GridUid)
- || nuke.OriginStation != owningStation)
- {
- continue;
- }
-
- paper.Content += $"{MetaData(nuke.Owner).EntityName} - {nuke.Code}";
- break;
+ _paper.SetContent(uid, paperContent);
}
}
///
- /// Send a nuclear code to all communication consoles
+ /// Send a nuclear code to all faxes on that station which are authorized to receive nuke codes.
///
- /// True if at least one console received codes
+ /// True if at least one fax received codes
public bool SendNukeCodes(EntityUid station)
{
if (!HasComp(station))
@@ -60,20 +46,17 @@ namespace Content.Server.Nuke
return false;
}
- // todo: this should probably be handled by fax system
var wasSent = false;
- var consoles = EntityQuery();
- foreach (var (console, transform) in consoles)
+ var faxes = EntityManager.EntityQuery();
+ foreach (var fax in faxes)
{
- var owningStation = _station.GetOwningStation(console.Owner);
- if (owningStation == null || owningStation != station)
+ if (!fax.ReceiveNukeCodes || !TryGetRelativeNukeCode(fax.Owner, out var paperContent, station))
{
continue;
}
- var consolePos = transform.MapPosition;
- var uid = Spawn(NukePaperPrototype, consolePos);
- SetupPaper(uid, station);
+ var printout = new FaxPrintout(paperContent, Loc.GetString("nuke-codes-fax-paper-name"));
+ _faxSystem.Receive(fax.Owner, printout, null, fax);
wasSent = true;
}
@@ -86,5 +69,35 @@ namespace Content.Server.Nuke
return wasSent;
}
+
+ private bool TryGetRelativeNukeCode(
+ EntityUid uid,
+ [NotNullWhen(true)] out string? nukeCode,
+ EntityUid? station = null,
+ TransformComponent? transform = null)
+ {
+ nukeCode = null;
+ if (!Resolve(uid, ref transform))
+ {
+ return false;
+ }
+
+ var owningStation = station ?? _station.GetOwningStation(uid);
+
+ // Find the first nuke that matches the passed location.
+ foreach (var nuke in EntityQuery())
+ {
+ if (owningStation == null && nuke.OriginMapGrid != (transform.MapID, transform.GridUid)
+ || nuke.OriginStation != owningStation)
+ {
+ continue;
+ }
+
+ nukeCode = Loc.GetString("nuke-codes-message", ("name", MetaData(nuke.Owner).EntityName), ("code", nuke.Code));
+ return true;
+ }
+
+ return false;
+ }
}
}
diff --git a/Content.Shared/Fax/FaxVisuals.cs b/Content.Shared/Fax/FaxVisuals.cs
new file mode 100644
index 0000000000..bfda60cdec
--- /dev/null
+++ b/Content.Shared/Fax/FaxVisuals.cs
@@ -0,0 +1,17 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Fax;
+
+[Serializable, NetSerializable]
+public enum FaxMachineVisuals : byte
+{
+ VisualState,
+}
+
+[Serializable, NetSerializable]
+public enum FaxMachineVisualState : byte
+{
+ Normal,
+ Inserting,
+ Printing
+}
diff --git a/Content.Shared/Fax/SharedFax.cs b/Content.Shared/Fax/SharedFax.cs
new file mode 100644
index 0000000000..5a4a69f64a
--- /dev/null
+++ b/Content.Shared/Fax/SharedFax.cs
@@ -0,0 +1,53 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Fax;
+
+[Serializable, NetSerializable]
+public enum FaxUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed class FaxUiState : BoundUserInterfaceState
+{
+ public string DeviceName { get; }
+ public Dictionary AvailablePeers { get; }
+ public string? DestinationAddress { get; }
+ public bool IsPaperInserted { get; }
+ public bool CanSend { get; }
+
+ public FaxUiState(string deviceName,
+ Dictionary peers,
+ bool canSend,
+ bool isPaperInserted,
+ string? destAddress)
+ {
+ DeviceName = deviceName;
+ AvailablePeers = peers;
+ IsPaperInserted = isPaperInserted;
+ CanSend = canSend;
+ DestinationAddress = destAddress;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class FaxSendMessage : BoundUserInterfaceMessage
+{
+}
+
+[Serializable, NetSerializable]
+public sealed class FaxRefreshMessage : BoundUserInterfaceMessage
+{
+}
+
+[Serializable, NetSerializable]
+public sealed class FaxDestinationMessage : BoundUserInterfaceMessage
+{
+ public string Address { get; }
+
+ public FaxDestinationMessage(string address)
+ {
+ Address = address;
+ }
+}
diff --git a/Resources/Audio/Machines/high_tech_confirm.ogg b/Resources/Audio/Machines/high_tech_confirm.ogg
new file mode 100644
index 0000000000..473266570b
Binary files /dev/null and b/Resources/Audio/Machines/high_tech_confirm.ogg differ
diff --git a/Resources/Audio/Machines/license.txt b/Resources/Audio/Machines/license.txt
index 71c2b9b9da..6a51ade8a9 100644
--- a/Resources/Audio/Machines/license.txt
+++ b/Resources/Audio/Machines/license.txt
@@ -20,4 +20,12 @@ machine_vend_hot_drink.ogg original from https://freesound.org/people/waxsocks/s
scan_loop.ogg from https://freesound.org/people/steaq/sounds/509249/ CC-0 by steaq
-scan_finish.ogg from https://freesound.org/people/pan14/sounds/263133/ CC-0 by pan14
\ No newline at end of file
+scan_finish.ogg from https://freesound.org/people/pan14/sounds/263133/ CC-0 by pan14
+
+printer.ogg from https://github.com/tgstation/tgstation/blob/31c5aaf4b885a50c9d5a0777e2647af64e7193bf/sound/machines/printer.ogg
+
+high_tech_confirm.ogg from https://github.com/tgstation/tgstation/blob/14aa5d2d8efca6370778d730e36ffcf258b9352e/sound/machines/high_tech_confirm.ogg
+
+tray_eject.ogg from https://github.com/tgstation/tgstation/blob/3eeba3899f22638595333c63b7b7433001f91bb2/sound/machines/eject.ogg
+
+scanning.ogg from https://freesound.org/people/SamuelGremaud/sounds/455375/ and edited
\ No newline at end of file
diff --git a/Resources/Audio/Machines/printer.ogg b/Resources/Audio/Machines/printer.ogg
new file mode 100644
index 0000000000..6916a695bf
Binary files /dev/null and b/Resources/Audio/Machines/printer.ogg differ
diff --git a/Resources/Audio/Machines/scanning.ogg b/Resources/Audio/Machines/scanning.ogg
new file mode 100644
index 0000000000..86e7da6f97
Binary files /dev/null and b/Resources/Audio/Machines/scanning.ogg differ
diff --git a/Resources/Audio/Machines/tray_eject.ogg b/Resources/Audio/Machines/tray_eject.ogg
new file mode 100644
index 0000000000..0e20c14ba4
Binary files /dev/null and b/Resources/Audio/Machines/tray_eject.ogg differ
diff --git a/Resources/Locale/en-US/devices/device-network.ftl b/Resources/Locale/en-US/devices/device-network.ftl
index fe3025ffac..e39a465d9a 100644
--- a/Resources/Locale/en-US/devices/device-network.ftl
+++ b/Resources/Locale/en-US/devices/device-network.ftl
@@ -4,6 +4,7 @@ device-frequency-prototype-name-suit-sensors = Suit Sensors
device-frequency-prototype-name-lights = Smart Lights
device-frequency-prototype-name-mailing-units = Mailing Units
device-frequency-prototype-name-pdas = PDAs
+device-frequency-prototype-name-fax = Fax
## camera frequencies
device-frequency-prototype-name-surveillance-camera-test = Subnet Test
diff --git a/Resources/Locale/en-US/fax/fax.ftl b/Resources/Locale/en-US/fax/fax.ftl
new file mode 100644
index 0000000000..a99cdade06
--- /dev/null
+++ b/Resources/Locale/en-US/fax/fax.ftl
@@ -0,0 +1,20 @@
+fax-machine-popup-source-unknown = unknown
+fax-machine-popup-received = Received correspondence from { $from }.
+fax-machine-popup-name-long = Fax name is too long
+fax-machine-popup-name-exist = Fax with same name already exist in network
+fax-machine-popup-name-set = Fax name has been updated
+
+fax-machine-dialog-rename = Rename
+fax-machine-dialog-field-name = Name
+
+fax-machine-ui-window = Fax Machine
+fax-machine-ui-send-button = Send
+fax-machine-ui-refresh-button = Refresh
+fax-machine-ui-no-peers = No Peers
+fax-machine-ui-to = To:
+fax-machine-ui-from = From:
+fax-machine-ui-paper = Paper:
+fax-machine-ui-paper-inserted = Paper in tray
+fax-machine-ui-paper-not-inserted = No paper
+
+fax-machine-chat-notify = Received new fax message on "{$fax}" fax
diff --git a/Resources/Locale/en-US/nuke/nuke-component.ftl b/Resources/Locale/en-US/nuke/nuke-component.ftl
index 0624d2b6b3..c0a64b42ce 100644
--- a/Resources/Locale/en-US/nuke/nuke-component.ftl
+++ b/Resources/Locale/en-US/nuke/nuke-component.ftl
@@ -32,3 +32,8 @@ nuke-label-nanotrasen = NT-{$serial}
# do you even need this one? It's more funnier to say that
# the Syndicate stole a NT nuke
nuke-label-syndicate = SYN-{$serial}
+
+# Codes
+nuke-codes-message = [color=red]TOP SECRET![/color]
+ Nuclear device activation code: {$name} - {$code}
+nuke-codes-fax-paper-name = nuclear authentication codes
diff --git a/Resources/Locale/en-US/verbs/verb-system.ftl b/Resources/Locale/en-US/verbs/verb-system.ftl
index bad305dafe..6ed0b94858 100644
--- a/Resources/Locale/en-US/verbs/verb-system.ftl
+++ b/Resources/Locale/en-US/verbs/verb-system.ftl
@@ -25,6 +25,7 @@ verb-categories-instrument-style = Instrument Style
verb-categories-set-sensor = Sensor
verb-categories-timer = Set Delay
verb-categories-lever = Lever
+verb-categories-fax = Set Destination
verb-common-toggle-light = Toggle light
verb-common-close = Close
diff --git a/Resources/Prototypes/Device/devicenet_frequencies.yml b/Resources/Prototypes/Device/devicenet_frequencies.yml
index 28c77028c8..f19598ac5c 100644
--- a/Resources/Prototypes/Device/devicenet_frequencies.yml
+++ b/Resources/Prototypes/Device/devicenet_frequencies.yml
@@ -74,3 +74,8 @@
id: PDA
name: device-frequency-prototype-name-pdas
frequency: 2202
+
+- type: deviceFrequency
+ id: Fax
+ name: device-frequency-prototype-name-fax
+ frequency: 2640
diff --git a/Resources/Prototypes/Entities/Objects/Misc/paper.yml b/Resources/Prototypes/Entities/Objects/Misc/paper.yml
index 4ae8572d1f..cbb7ddba32 100644
--- a/Resources/Prototypes/Entities/Objects/Misc/paper.yml
+++ b/Resources/Prototypes/Entities/Objects/Misc/paper.yml
@@ -57,9 +57,6 @@
components:
- type: NukeCodePaper
- type: Paper
- content: |
- [color=red]TOP SECRET![/color]
- Nuclear device activation code:
- type: entity
name: pen
diff --git a/Resources/Prototypes/Entities/Structures/Machines/fax_machine.yml b/Resources/Prototypes/Entities/Structures/Machines/fax_machine.yml
new file mode 100644
index 0000000000..97d55d7185
--- /dev/null
+++ b/Resources/Prototypes/Entities/Structures/Machines/fax_machine.yml
@@ -0,0 +1,106 @@
+- type: entity
+ parent: BaseMachinePowered
+ id: FaxMachineBase
+ name: long range fax machine
+ description: Bluespace technologies on the application of bureaucracy.
+ components:
+ - type: Sprite
+ sprite: Structures/Machines/fax_machine.rsi
+ netsync: false
+ layers:
+ - state: icon
+ map: ["base"]
+ - type: Icon
+ sprite: Structures/Machines/fax_machine.rsi
+ state: icon
+ - type: Appearance
+ - type: Physics
+ bodyType: Static
+ - type: Fixtures
+ fixtures:
+ - shape:
+ !type:PhysShapeAabb
+ bounds: "-0.25,-0.25,0.25,0.25"
+ density: 25
+ mask:
+ - MachineMask
+ layer:
+ - MachineLayer
+ - type: ActivatableUI
+ key: enum.FaxUiKey.Key
+ - type: ActivatableUIRequiresPower
+ - type: UserInterface
+ interfaces:
+ - key: enum.FaxUiKey.Key
+ type: FaxBoundUi
+ - type: ApcPowerReceiver
+ powerLoad: 250
+ - type: FaxMachine
+ paperSlot:
+ insertSound: /Audio/Machines/scanning.ogg
+ ejectSound: /Audio/Machines/tray_eject.ogg
+ whitelist:
+ components:
+ - Paper
+ - type: GenericVisualizer
+ visuals:
+ enum.PowerDeviceVisuals.Powered:
+ base:
+ True: { state: idle }
+ False: { state: icon }
+ enum.FaxMachineVisuals.VisualState:
+ base:
+ Inserting: { state: inserting }
+ Printing: { state: printing }
+ - type: ItemSlots
+ - type: ContainerContainer
+ containers:
+ Paper: !type:ContainerSlot
+ - type: DeviceNetworkRequiresPower
+ - type: DeviceNetwork
+ deviceNetId: Wireless
+ receiveFrequencyId: Fax
+ transmitFrequencyId: Fax
+
+# Special
+- type: entity
+ parent: FaxMachineBase
+ id: FaxMachineCentcom
+ name: centcom long range fax machine
+ suffix: Centcom
+ components:
+ - type: Sprite
+ layers:
+ - state: icon
+ map: [ "base" ]
+ color: "#bfe3ff"
+ - type: FaxMachine
+ name: "Central Command"
+ notifyAdmins: true
+
+- type: entity
+ parent: FaxMachineBase
+ id: FaxMachineSyndie
+ name: syndicate long range fax machine
+ suffix: Syndicate
+ components:
+ - type: Sprite
+ layers:
+ - state: icon
+ map: [ "base" ]
+ color: "#a3a3a3"
+ - type: FaxMachine
+ name: "ERR*?*%!"
+ responsePings: false
+ emagged: true
+ notifyAdmins: true
+
+- type: entity
+ parent: FaxMachineBase
+ id: FaxMachineCaptain
+ name: captain long range fax machine
+ suffix: Centcom
+ components:
+ - type: FaxMachine
+ name: "Captain's Office"
+ receiveNukeCodes: true
diff --git a/Resources/Textures/Structures/Machines/fax_machine.rsi/icon.png b/Resources/Textures/Structures/Machines/fax_machine.rsi/icon.png
new file mode 100644
index 0000000000..9ad556d3c4
Binary files /dev/null and b/Resources/Textures/Structures/Machines/fax_machine.rsi/icon.png differ
diff --git a/Resources/Textures/Structures/Machines/fax_machine.rsi/idle.png b/Resources/Textures/Structures/Machines/fax_machine.rsi/idle.png
new file mode 100644
index 0000000000..740bb752d0
Binary files /dev/null and b/Resources/Textures/Structures/Machines/fax_machine.rsi/idle.png differ
diff --git a/Resources/Textures/Structures/Machines/fax_machine.rsi/inserting.png b/Resources/Textures/Structures/Machines/fax_machine.rsi/inserting.png
new file mode 100644
index 0000000000..395f115b50
Binary files /dev/null and b/Resources/Textures/Structures/Machines/fax_machine.rsi/inserting.png differ
diff --git a/Resources/Textures/Structures/Machines/fax_machine.rsi/meta.json b/Resources/Textures/Structures/Machines/fax_machine.rsi/meta.json
new file mode 100644
index 0000000000..557e0302b1
--- /dev/null
+++ b/Resources/Textures/Structures/Machines/fax_machine.rsi/meta.json
@@ -0,0 +1,89 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from vgstation at commit https://github.com/vgstation-coders/vgstation13/commit/695aafae161eebebdea00d4a5d624ec154d06be2",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "icon"
+ },
+ {
+ "name": "idle",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "inserting",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "printing",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ }
+ ]
+}
diff --git a/Resources/Textures/Structures/Machines/fax_machine.rsi/printing.png b/Resources/Textures/Structures/Machines/fax_machine.rsi/printing.png
new file mode 100644
index 0000000000..9c51ad8d12
Binary files /dev/null and b/Resources/Textures/Structures/Machines/fax_machine.rsi/printing.png differ