diff --git a/Content.Client/_Goobstation/Factory/ConstructorSystem.cs b/Content.Client/_Goobstation/Factory/ConstructorSystem.cs
new file mode 100644
index 00000000000..9a9f5c37b20
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/ConstructorSystem.cs
@@ -0,0 +1,5 @@
+using Content.Shared._Goobstation.Factory;
+
+namespace Content.Client._Goobstation.Factory;
+
+public sealed class ConstructorSystem : SharedConstructorSystem;
diff --git a/Content.Client/_Goobstation/Factory/InteractorSystem.cs b/Content.Client/_Goobstation/Factory/InteractorSystem.cs
new file mode 100644
index 00000000000..89bec830538
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/InteractorSystem.cs
@@ -0,0 +1,5 @@
+using Content.Shared._Goobstation.Factory;
+
+namespace Content.Client._Goobstation.Factory;
+
+public sealed class InteractorSystem : SharedInteractorSystem;
diff --git a/Content.Client/_Goobstation/Factory/RoboticArmAnimationSystem.cs b/Content.Client/_Goobstation/Factory/RoboticArmAnimationSystem.cs
new file mode 100644
index 00000000000..5e683bd0f0f
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/RoboticArmAnimationSystem.cs
@@ -0,0 +1,55 @@
+using Content.Shared._Goobstation.Factory;
+using Robust.Client.GameObjects;
+using Robust.Shared.Timing;
+
+namespace Content.Client._Goobstation.Factory;
+
+///
+/// Animations robotic arm's arm layer swinging.
+/// Can't be done with engine AnimationPlayer as it can't animate individual layers.
+///
+public sealed class RoboticArmAnimationSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ public override void FrameUpdate(float frameTime)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (comp.ItemSlot == null)
+ continue;
+
+ if (comp.NextMove is {} nextMove)
+ Animate((uid, comp), nextMove);
+ else
+ Reset((uid, comp));
+ }
+ }
+
+ private void Animate(Entity ent, TimeSpan nextMove)
+ {
+ if (!TryComp(ent, out var sprite))
+ return;
+
+ var started = nextMove - ent.Comp.MoveDelay;
+ // 0-1 unless something weird happens
+ var progress = (_timing.CurTime - started) / ent.Comp.MoveDelay;
+ if (!ent.Comp.HasItem) // returning to the resting position when emptied
+ progress = 1f - progress;
+ else if (progress > 1f) // Mono
+ progress = 2f - progress;
+ progress = Math.Clamp(progress, 0f, 1f); // Mono
+ var angle = Angle.FromDegrees(progress * 180f);
+ sprite.LayerSetRotation(RoboticArmLayers.Arm, angle);
+ }
+
+ private void Reset(Entity ent)
+ {
+ if (!TryComp(ent, out var sprite))
+ return;
+
+ var angle = ent.Comp.HasItem ? new Angle(Math.PI) : Angle.Zero;
+ sprite.LayerSetRotation(RoboticArmLayers.Arm, angle);
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/ConstructorBUI.cs b/Content.Client/_Goobstation/Factory/UI/ConstructorBUI.cs
new file mode 100644
index 00000000000..f45bb7197b2
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/ConstructorBUI.cs
@@ -0,0 +1,186 @@
+using Content.Client.Construction;
+using Content.Client.Construction.UI;
+using Content.Shared._Goobstation.Factory;
+using Content.Shared.Construction.Prototypes;
+using Content.Shared.Whitelist;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Prototypes;
+using System.Linq;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+public sealed class ConstructorBUI : BoundUserInterface
+{
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ private readonly ConstructionSystem _construction;
+ private readonly EntityWhitelistSystem _whitelist;
+ private readonly SpriteSystem _sprite;
+
+ private ConstructionMenu? _menu;
+ private string? _id;
+ private List _recipes = new();
+ private readonly LocId _favoriteCatName = "construction-category-favorites";
+ private readonly LocId _forAllCategoryName = "construction-category-all";
+
+ public ConstructorBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ _construction = EntMan.System();
+ _whitelist = EntMan.System();
+ _sprite = EntMan.System();
+
+ _id = EntMan.GetComponentOrNull(owner)?.Construction;
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ // god BLESS whoever made construction ui for having it so decoupled <3
+ _menu = this.CreateWindow();
+ PopulateCategories();
+ PopulateRecipes(string.Empty, string.Empty);
+ _menu.PopulateRecipes += (_, args) => PopulateRecipes(args.Item1, args.Item2);
+ _menu.RecipeSelected += (_, item) =>
+ {
+ _menu.ClearRecipeInfo();
+ if (item != null && item.Prototype != null)
+ {
+ _id = item.Prototype.ID;
+ _menu.SetRecipeInfo(item.Prototype.Name ?? "", item.Prototype.Description ?? "", item?.TargetPrototype,
+ item!.Prototype.Type != ConstructionType.Item, true); // TODO: favourites
+
+ GenerateStepList(item.Prototype);
+ }
+ else
+ {
+ _id = null;
+ }
+ };
+ _menu.BuildButtonToggled += (_, _) =>
+ {
+ SendPredictedMessage(new ConstructorSetProtoMessage(_id));
+ _menu.Close();
+ };
+ }
+
+ private void PopulateCategories(string? selected = null)
+ {
+ if (_menu is not {} menu)
+ return;
+
+ var categories = new HashSet();
+
+ foreach (var prototype in _proto.EnumeratePrototypes())
+ {
+ var category = prototype.Category;
+
+ if (!string.IsNullOrEmpty(category))
+ categories.Add(category);
+ }
+
+ var categoriesArray = new string[categories.Count + 1];
+
+ // hard-coded to show all recipes
+ var idx = 0;
+ categoriesArray[idx++] = _forAllCategoryName;
+
+ foreach (var cat in categories.OrderBy(Loc.GetString))
+ {
+ categoriesArray[idx++] = cat;
+ }
+
+ menu.OptionCategories.Clear();
+
+ for (var i = 0; i < categoriesArray.Length; i++)
+ {
+ menu.OptionCategories.AddItem(Loc.GetString(categoriesArray[i]), i);
+
+ if (!string.IsNullOrEmpty(selected) && selected == categoriesArray[i])
+ menu.OptionCategories.SelectId(i);
+ }
+
+ menu.Categories = categoriesArray;
+ }
+
+ // copypasted and optimised from ConstructionMenuPresenter
+ private void PopulateRecipes(string search, string category)
+ {
+ if (PlayerManager.LocalEntity is not { } user
+ || _menu is not { } menu)
+ return;
+
+ search = search.Trim().ToLowerInvariant();
+ var searching = !string.IsNullOrEmpty(search);
+ var isEmptyCategory = string.IsNullOrEmpty(category) || category == _forAllCategoryName;
+
+ _recipes.Clear();
+ foreach (var recipe in _proto.EnumeratePrototypes())
+ {
+ if (recipe.Hide)
+ continue;
+
+ if (_whitelist.IsWhitelistFail(recipe.EntityWhitelist, user))
+ continue;
+
+ if (searching
+ && recipe.Name != null
+ && !recipe.Name.ToLowerInvariant().Contains(search))
+ continue;
+
+ if (!isEmptyCategory)
+ {
+ // TODO: when favourites get sent from server do this
+ // currently its specific to the G menu
+ //if (!_favoritedRecipes.Contains(recipe))
+ if (category == _favoriteCatName)
+ continue;
+ else if (recipe.Category != category)
+ continue;
+ }
+
+ if (!_construction!.TryGetRecipePrototype(recipe.ID, out var targetProtoId))
+ continue;
+
+ if (!_proto.TryIndex(targetProtoId, out EntityPrototype? proto))
+ continue;
+
+ _recipes.Add(new(recipe, proto));
+ }
+
+ _recipes.Sort((a, b) => string.Compare(a.Prototype.Name, b.Prototype.Name, StringComparison.InvariantCulture));
+
+ var recipesList = menu.Recipes;
+ recipesList.PopulateList(_recipes);
+
+ menu.RecipesGridScrollContainer.Visible = false;
+ menu.Recipes.Visible = true;
+ }
+
+ private void GenerateStepList(ConstructionPrototype proto)
+ {
+ if (_construction.GetGuide(proto) is not { } guide
+ || _menu is not { } menu)
+ return;
+
+ var list = menu.RecipeStepList;
+ foreach (var entry in guide.Entries)
+ {
+ var text = entry.Arguments != null
+ ? Loc.GetString(entry.Localization, entry.Arguments)
+ : Loc.GetString(entry.Localization);
+
+ if (entry.EntryNumber is { } number)
+ text = Loc.GetString("construction-presenter-step-wrapper",
+ ("step-number", number), ("text", text));
+
+ // The padding needs to be applied regardless of text length... (See PadLeft documentation)
+ text = text.PadLeft(text.Length + entry.Padding);
+
+ var icon = entry.Icon != null ? _sprite.Frame0(entry.Icon) : Texture.Transparent;
+ list.AddItem(text, icon, false);
+ }
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/LabelFilterBUI.cs b/Content.Client/_Goobstation/Factory/UI/LabelFilterBUI.cs
new file mode 100644
index 00000000000..90656da2acf
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/LabelFilterBUI.cs
@@ -0,0 +1,22 @@
+using Content.Shared._Goobstation.Factory.Filters;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+public sealed class LabelFilterBUI : BoundUserInterface
+{
+ private LabelFilterWindow? _window;
+
+ public LabelFilterBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.SetEntity(Owner);
+ _window.OnSetLabel += label => SendPredictedMessage(new LabelFilterSetLabelMessage(label));
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml b/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml
new file mode 100644
index 00000000000..fcd5c777955
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml.cs b/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml.cs
new file mode 100644
index 00000000000..5e716ca057b
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml.cs
@@ -0,0 +1,32 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared._Goobstation.Factory.Filters;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class LabelFilterWindow : FancyWindow
+{
+ [Dependency] private readonly EntityManager _entMan = default!;
+
+ public event Action? OnSetLabel;
+
+ public LabelFilterWindow()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ LabelEdit.OnTextChanged += _ => OnSetLabel?.Invoke(LabelEdit.Text);
+ }
+
+ public void SetEntity(EntityUid uid)
+ {
+ if (!_entMan.TryGetComponent(uid, out var comp))
+ return;
+
+ var max = comp.MaxLength;
+ LabelEdit.IsValid = label => label.Length < max;
+ LabelEdit.Text = comp.Label;
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/NameFilterBUI.cs b/Content.Client/_Goobstation/Factory/UI/NameFilterBUI.cs
new file mode 100644
index 00000000000..12821db945a
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/NameFilterBUI.cs
@@ -0,0 +1,23 @@
+using Content.Shared._Goobstation.Factory.Filters;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+public sealed class NameFilterBUI : BoundUserInterface
+{
+ private NameFilterWindow? _window;
+
+ public NameFilterBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.SetEntity(Owner);
+ _window.OnSetName += name => SendPredictedMessage(new NameFilterSetNameMessage(name));
+ _window.OnSetMode += mode => SendPredictedMessage(new NameFilterSetModeMessage(mode));
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml b/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml
new file mode 100644
index 00000000000..8b17039fc48
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml.cs b/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml.cs
new file mode 100644
index 00000000000..a4c2114c78d
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml.cs
@@ -0,0 +1,45 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared._Goobstation.Factory.Filters;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class NameFilterWindow : FancyWindow
+{
+ [Dependency] private readonly EntityManager _entMan = default!;
+
+ public event Action? OnSetName;
+ public event Action? OnSetMode;
+
+ public NameFilterWindow()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ foreach (var mode in Enum.GetValues())
+ {
+ ModeButton.AddItem(Loc.GetString($"name-filter-mode-{mode}"), (int) mode);
+ }
+
+ ModeButton.OnItemSelected += args =>
+ {
+ ModeButton.SelectId(args.Id);
+ OnSetMode?.Invoke((NameFilterMode) args.Id);
+ };
+
+ NameEdit.OnTextChanged += _ => OnSetName?.Invoke(NameEdit.Text);
+ }
+
+ public void SetEntity(EntityUid uid)
+ {
+ if (!_entMan.TryGetComponent(uid, out var comp))
+ return;
+
+ ModeButton.SelectId((int) comp.Mode);
+ var max = comp.MaxLength;
+ NameEdit.IsValid = name => name.Length < max;
+ NameEdit.Text = comp.Name;
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/PressureFilterBUI.cs b/Content.Client/_Goobstation/Factory/UI/PressureFilterBUI.cs
new file mode 100644
index 00000000000..e7d1d45fc26
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/PressureFilterBUI.cs
@@ -0,0 +1,23 @@
+using Content.Shared._Goobstation.Factory.Filters;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+public sealed class PressureFilterBUI : BoundUserInterface
+{
+ private PressureFilterWindow? _window;
+
+ public PressureFilterBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.SetEntity(Owner);
+ _window.OnSetMin += min => SendPredictedMessage(new PressureFilterSetMinMessage(min));
+ _window.OnSetMax += max => SendPredictedMessage(new PressureFilterSetMaxMessage(max));
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml b/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml
new file mode 100644
index 00000000000..206508a9377
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml.cs b/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml.cs
new file mode 100644
index 00000000000..dbe958568ba
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml.cs
@@ -0,0 +1,60 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared._Goobstation.Factory.Filters;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class PressureFilterWindow : FancyWindow
+{
+ [Dependency] private readonly EntityManager _entMan = default!;
+
+ public event Action? OnSetMin;
+ public event Action? OnSetMax;
+
+ private float _min, _max;
+
+ public PressureFilterWindow()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ MinEdit.OnTextChanged += _ => UpdateButtons();
+
+ MinConfirmButton.OnPressed += _ =>
+ {
+ if (float.TryParse(MinEdit.Text, out var min))
+ OnSetMin?.Invoke(min);
+ };
+
+ MaxEdit.OnTextChanged += _ => UpdateButtons();
+
+ MaxConfirmButton.OnPressed += _ =>
+ {
+ if (float.TryParse(MaxEdit.Text, out var max))
+ OnSetMax?.Invoke(max);
+ };
+
+ OnSetMin += min => { _min = min; UpdateButtons(); };
+ OnSetMax += max => { _max = max; UpdateButtons(); };
+ }
+
+ public void SetEntity(EntityUid uid)
+ {
+ if (!_entMan.TryGetComponent(uid, out var comp))
+ return;
+
+ _min = comp.Min;
+ _max = comp.Max;
+ MinEdit.Text = _min.ToString();
+ MaxEdit.Text = _max.ToString();
+ UpdateButtons();
+ }
+
+ private void UpdateButtons()
+ {
+ MinConfirmButton.Disabled = !float.TryParse(MinEdit.Text, out var min) || min < 0f || min > _max || min == _min;
+ MaxConfirmButton.Disabled = !float.TryParse(MaxEdit.Text, out var max) || max < _min || max == _max;
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/StackFilterBUI.cs b/Content.Client/_Goobstation/Factory/UI/StackFilterBUI.cs
new file mode 100644
index 00000000000..6709d60446d
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/StackFilterBUI.cs
@@ -0,0 +1,23 @@
+using Content.Shared._Goobstation.Factory.Filters;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+public sealed class StackFilterBUI : BoundUserInterface
+{
+ private StackFilterWindow? _window;
+
+ public StackFilterBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.SetEntity(Owner);
+ _window.OnSetMin += min => SendPredictedMessage(new StackFilterSetMinMessage(min));
+ _window.OnSetSize += size => SendPredictedMessage(new StackFilterSetSizeMessage(size));
+ }
+}
diff --git a/Content.Client/_Goobstation/Factory/UI/StackFilterWindow.xaml b/Content.Client/_Goobstation/Factory/UI/StackFilterWindow.xaml
new file mode 100644
index 00000000000..276341adda0
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/StackFilterWindow.xaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_Goobstation/Factory/UI/StackFilterWindow.xaml.cs b/Content.Client/_Goobstation/Factory/UI/StackFilterWindow.xaml.cs
new file mode 100644
index 00000000000..3a2996e02bc
--- /dev/null
+++ b/Content.Client/_Goobstation/Factory/UI/StackFilterWindow.xaml.cs
@@ -0,0 +1,54 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared._Goobstation.Factory.Filters;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._Goobstation.Factory.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class StackFilterWindow : FancyWindow
+{
+ [Dependency] private readonly EntityManager _entMan = default!;
+
+ public event Action? OnSetMin;
+ public event Action? OnSetSize;
+
+ public StackFilterWindow()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ MinEdit.OnTextChanged += _ =>
+ {
+ MinConfirmButton.Disabled = !int.TryParse(MinEdit.Text, out var min) || min < 1;
+ };
+
+ MinConfirmButton.OnPressed += _ =>
+ {
+ if (int.TryParse(MinEdit.Text, out var min))
+ OnSetMin?.Invoke(min);
+ };
+
+ SizeEdit.OnTextChanged += _ =>
+ {
+ SizeConfirmButton.Disabled = !int.TryParse(SizeEdit.Text, out var size) || size < 0;
+ };
+
+ SizeConfirmButton.OnPressed += _ =>
+ {
+ if (int.TryParse(SizeEdit.Text, out var size))
+ OnSetSize?.Invoke(size);
+ };
+ }
+
+ public void SetEntity(EntityUid uid)
+ {
+ if (!_entMan.TryGetComponent(uid, out var comp))
+ return;
+
+ var min = comp.Min;
+ MinEdit.Text = min.ToString();
+ var size = comp.Size;
+ SizeEdit.Text = size.ToString();
+ }
+}
diff --git a/Content.Client/_Goobstation/Guidebook/Controls/GuideAutomationSlotsEmbed.cs b/Content.Client/_Goobstation/Guidebook/Controls/GuideAutomationSlotsEmbed.cs
new file mode 100644
index 00000000000..dc83f0a532f
--- /dev/null
+++ b/Content.Client/_Goobstation/Guidebook/Controls/GuideAutomationSlotsEmbed.cs
@@ -0,0 +1,47 @@
+using Content.Client.Guidebook.Controls;
+using Content.Client.Guidebook.Richtext;
+using Content.Shared._Goobstation.Factory;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Content.Client._Goobstation.Guidebook.Controls;
+
+///
+/// Lists all entities with .
+///
+public sealed partial class GuideAutomationSlotsEmbed : IDocumentTag
+{
+ [Dependency] private readonly IEntityManager _entMan = default!;
+ private readonly AutomationSystem _automation;
+
+ public GuideAutomationSlotsEmbed()
+ {
+ IoCManager.InjectDependencies(this);
+
+ _automation = _entMan.System();
+ }
+
+ bool IDocumentTag.TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control)
+ {
+ var scroll = new ScrollContainer()
+ {
+ MinHeight = 200f,
+ MaxHeight = 400f
+ };
+ var box = new BoxContainer()
+ {
+ Orientation = BoxContainer.LayoutOrientation.Vertical,
+ HorizontalExpand = true,
+ VerticalExpand = true
+ };
+ foreach (var id in _automation.Automatable)
+ {
+ box.AddChild(new GuideEntityEmbed(id, false, true));
+ }
+ scroll.AddChild(box);
+
+ control = scroll;
+ return true;
+ }
+}
diff --git a/Content.Server/Construction/ConstructionSystem.Initial.cs b/Content.Server/Construction/ConstructionSystem.Initial.cs
index 14df82e0c76..10b5d79aefe 100644
--- a/Content.Server/Construction/ConstructionSystem.Initial.cs
+++ b/Content.Server/Construction/ConstructionSystem.Initial.cs
@@ -1,4 +1,5 @@
using Content.Server.Construction.Components;
+using Content.Shared._Goobstation.Construction; // Goobstation
using Content.Shared.ActionBlocker;
using Content.Shared.Construction;
using Content.Shared.Construction.Prototypes;
@@ -10,6 +11,7 @@
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Interaction;
using Content.Shared.Inventory;
+using Content.Shared.Mind.Components; // Goobstation
using Content.Shared.Storage;
using Content.Shared.Whitelist;
using Robust.Shared.Containers;
@@ -24,6 +26,7 @@ namespace Content.Server.Construction
{
public sealed partial class ConstructionSystem
{
+ [Dependency] private readonly IComponentFactory _factory = default!; // Goobstation
[Dependency] private readonly InventorySystem _inventorySystem = default!;
[Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
@@ -61,6 +64,11 @@ private IEnumerable EnumerateNearby(EntityUid user)
yield return item;
}
+ // - lets slimepeople and constructors use their storageAdd commentMore actions
+ if (TryComp(user, out var userStorage))
+ foreach (var userItem in userStorage.Container.ContainedEntities!)
+ yield return userItem;
+ //
if (_inventorySystem.TryGetContainerSlotEnumerator(user, out var containerSlotEnumerator))
{
@@ -351,7 +359,8 @@ public async Task TryStartItemConstruction(string prototype, EntityUid use
if (!_actionBlocker.CanInteract(user, null))
return false;
- if (!HasComp(user))
+ if (HasComp(user)
+ && !HasComp(user)) // Goobstation - don't require hands for constructor
return false;
foreach (var condition in constructionPrototype.Conditions)
@@ -394,6 +403,11 @@ public async Task TryStartItemConstruction(string prototype, EntityUid use
Transform(user).Coordinates) is not { Valid: true } item)
return false;
+ //
+ var constructedEv = new ConstructedEvent(item);
+ RaiseLocalEvent(user, ref constructedEv);
+ //
+
// Just in case this is a stack, attempt to merge it. If it isn't a stack, this will just normally pick up
// or drop the item as normal.
_stackSystem.TryMergeToHands(item, user);
@@ -403,78 +417,96 @@ public async Task TryStartItemConstruction(string prototype, EntityUid use
// LEGACY CODE. See warning at the top of the file!
private async void HandleStartStructureConstruction(TryStartStructureConstructionMessage ev, EntitySessionEventArgs args)
{
- if (!PrototypeManager.TryIndex(ev.PrototypeName, out ConstructionPrototype? constructionPrototype))
- {
- Log.Error($"Tried to start construction of invalid recipe '{ev.PrototypeName}'!");
- RaiseNetworkEvent(new AckStructureConstructionMessage(ev.Ack));
- return;
- }
+ // - use public API
+ if (args.SenderSession.AttachedEntity is {} user)
+ await TryStartStructureConstruction(user,
+ ev.PrototypeName,
+ GetCoordinates(ev.Location),
+ ev.Angle,
+ ev.Ack,
+ args.SenderSession);
+ }
- if (!PrototypeManager.TryIndex(constructionPrototype.Graph, out ConstructionGraphPrototype? constructionGraph))
+ ///
+ /// Goobstation - Taken out of HandleStartStructureConstruction
+ /// Changed to return false and only send the ack event to the user.
+ ///
+ public async Task TryStartStructureConstruction(EntityUid user,
+ string prototypeName,
+ EntityCoordinates location,
+ Angle angle,
+ int ack = 0,
+ ICommonSession? senderSession = null)
+ {
+ //
+ if (!PrototypeManager.TryIndex(prototypeName, out ConstructionPrototype? constructionPrototype))
{
- Log.Error($"Invalid construction graph '{constructionPrototype.Graph}' in recipe '{ev.PrototypeName}'!");
- RaiseNetworkEvent(new AckStructureConstructionMessage(ev.Ack));
- return;
+ Log.Error($"Tried to start construction of invalid recipe '{prototypeName}'!");
+ RaiseNetworkEvent(new AckStructureConstructionMessage(ack), user);
+ return false;
}
- if (args.SenderSession.AttachedEntity is not { Valid: true } user)
+ if (!PrototypeManager.TryIndex(constructionPrototype.Graph, out ConstructionGraphPrototype? constructionGraph))
{
- Log.Error($"Client sent {nameof(TryStartStructureConstructionMessage)} with no attached entity!");
- return;
+ Log.Error($"Invalid construction graph '{constructionPrototype.Graph}' in recipe '{prototypeName}'!");
+ RaiseNetworkEvent(new AckStructureConstructionMessage(ack), user);
+ return false;
}
if (_whitelistSystem.IsWhitelistFail(constructionPrototype.EntityWhitelist, user))
{
_popup.PopupEntity(Loc.GetString("construction-system-cannot-start"), user, user);
- return;
+ return false;
}
if (_container.IsEntityInContainer(user))
{
_popup.PopupEntity(Loc.GetString("construction-system-inside-container"), user, user);
- return;
+ return false;
}
var startNode = constructionGraph.Nodes[constructionPrototype.StartNode];
var targetNode = constructionGraph.Nodes[constructionPrototype.TargetNode];
var pathFind = constructionGraph.Path(startNode.Name, targetNode.Name);
-
- if (_beingBuilt.TryGetValue(args.SenderSession, out var set))
+ if (senderSession is {} session) // Goobstation - ignore check for constructor
{
- if (!set.Add(ev.Ack))
+ if (_beingBuilt.TryGetValue(session, out var set))
{
- _popup.PopupEntity(Loc.GetString("construction-system-already-building"), user, user);
- return;
+ if (!set.Add(ack))
+ {
+ _popup.PopupEntity(Loc.GetString("construction-system-already-building"), user, user);
+ return false;
+ }
+ }
+ else
+ {
+ var newSet = new HashSet {ack};
+ _beingBuilt[session] = newSet;
}
}
- else
- {
- var newSet = new HashSet { ev.Ack };
- _beingBuilt[args.SenderSession] = newSet;
- }
-
- var location = GetCoordinates(ev.Location);
foreach (var condition in constructionPrototype.Conditions)
{
- if (!condition.Condition(user, location, ev.Angle.GetCardinalDir()))
+ if (!condition.Condition(user, location, angle.GetCardinalDir()))
{
Cleanup();
- return;
+ return false;
}
}
void Cleanup()
{
- _beingBuilt[args.SenderSession].Remove(ev.Ack);
+ if (senderSession is {} session) // Goobstation - not added for constructor
+ _beingBuilt[session].Remove(ack);
}
+ HandsComponent? hands = null; // Goobstation
if (!_actionBlocker.CanInteract(user, null)
- || !TryComp(user, out HandsComponent? hands) || _handsSystem.GetActiveItem((user, hands)) == null)
+ || (senderSession != null && TryComp(user, out hands) && _handsSystem.GetActiveItem((user, hands)) == null)) // Goobstation - dont check hands for constructor
{
Cleanup();
- return;
+ return false;
}
var mapPos = _transformSystem.ToMapCoordinates(location);
@@ -483,64 +515,73 @@ void Cleanup()
if (!_interactionSystem.InRangeUnobstructed(user, mapPos, predicate: predicate))
{
Cleanup();
- return;
+ return false;
}
if (pathFind == null)
- throw new InvalidDataException($"Can't find path from starting node to target node in construction! Recipe: {ev.PrototypeName}");
+ throw new InvalidDataException($"Can't find path from starting node to target node in construction! Recipe: {prototypeName}");
var edge = startNode.GetEdge(pathFind[0].Name);
- if (edge == null)
- throw new InvalidDataException($"Can't find edge from starting node to the next node in pathfinding! Recipe: {ev.PrototypeName}");
-
- var valid = false;
+ if(edge == null)
+ throw new InvalidDataException($"Can't find edge from starting node to the next node in pathfinding! Recipe: {prototypeName}");
- if (_handsSystem.GetActiveItem((user, hands)) is not { Valid: true } holding)
+ if (senderSession != null) // Goobstation - don't check this for constructor machine
{
- Cleanup();
- return;
- }
+ var valid = false;
- // No support for conditions here!
+ if (_handsSystem.GetActiveItem((user, hands)) is not { Valid: true } holding) // Goobstation - don't check for constructor machine
+ {
+ Cleanup();
+ return false;
+ }
+ // No support for conditions here!
- foreach (var step in edge.Steps)
- {
- switch (step)
+ foreach (var step in edge.Steps)
{
- case EntityInsertConstructionGraphStep entityInsert:
- if (entityInsert.EntityValid(holding, EntityManager, Factory))
- valid = true;
+ switch (step)
+ {
+ case EntityInsertConstructionGraphStep entityInsert:
+ if (entityInsert.EntityValid(holding, EntityManager, _factory))
+ valid = true;
+ break;
+ case ToolConstructionGraphStep _:
+ throw new InvalidDataException("Invalid first step for item recipe!");
+ }
+
+ if (valid)
break;
- case ToolConstructionGraphStep _:
- throw new InvalidDataException("Invalid first step for item recipe!");
}
- if (valid)
- break;
+ if (!valid)
+ {
+ Cleanup();
+ return false;
+ }
}
- if (!valid)
- {
- Cleanup();
- return;
- }
if (await Construct(user,
- (ev.Ack + constructionPrototype.GetHashCode()).ToString(),
+ (ack + constructionPrototype.GetHashCode()).ToString(),
constructionGraph,
edge,
targetNode,
- GetCoordinates(ev.Location),
- constructionPrototype.CanRotate ? ev.Angle : Angle.Zero) is not { Valid: true } structure)
+ location,
+ constructionPrototype.CanRotate ? angle : Angle.Zero) is not {Valid: true} structure)
{
Cleanup();
- return;
+ return false;
}
- RaiseNetworkEvent(new AckStructureConstructionMessage(ev.Ack, GetNetEntity(structure)));
- _adminLogger.Add(LogType.Construction, LogImpact.Low, $"{ToPrettyString(user):player} has turned a {ev.PrototypeName} construction ghost into {ToPrettyString(structure)} at {Transform(structure).Coordinates}");
+ //
+ var constructedEv = new ConstructedEvent(structure);
+ RaiseLocalEvent(user, ref constructedEv);
+ //
+
+ RaiseNetworkEvent(new AckStructureConstructionMessage(ack, GetNetEntity(structure)), user);
+ _adminLogger.Add(LogType.Construction, LogImpact.Low, $"{ToPrettyString(user):player} has turned a {prototypeName} construction ghost into {ToPrettyString(structure)} at {Transform(structure).Coordinates}");
Cleanup();
+ return true;
}
}
}
diff --git a/Content.Server/Fax/FaxSystem.cs b/Content.Server/Fax/FaxSystem.cs
index fdc3985b703..e8a1d81cf6a 100644
--- a/Content.Server/Fax/FaxSystem.cs
+++ b/Content.Server/Fax/FaxSystem.cs
@@ -154,7 +154,12 @@ private void ProcessSendingTimeout(EntityUid uid, float frameTime, FaxMachineCom
private void OnComponentInit(EntityUid uid, FaxMachineComponent component, ComponentInit args)
{
- _itemSlotsSystem.AddItemSlot(uid, PaperSlotId, component.PaperSlot);
+ // - define the slot in ItemSlots instead of adding it
+ if (_itemSlotsSystem.TryGetSlot(uid, PaperSlotId, out var slot))
+ component.PaperSlot = slot;
+ else
+ _itemSlotsSystem.AddItemSlot(uid, PaperSlotId, component.PaperSlot);
+ //
UpdateAppearance(uid, component);
Refresh(uid, component);
}
@@ -489,6 +494,9 @@ public void Copy(EntityUid uid, FaxMachineComponent? component, FaxCopyMessage a
UpdateUserInterface(uid, component);
+ if (!args.Actor.IsValid()) // Goobstation - no log for automation
+ return;
+
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(args.Actor):actor} " +
@@ -574,6 +582,7 @@ public void Send(EntityUid uid, FaxMachineComponent? component, FaxSendMessage a
_deviceNetworkSystem.QueuePacket(uid, component.DestinationFaxAddress, payload);
+ if (!args.Actor.IsValid()) // Goobstation - no log for automation
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(args.Actor):actor} " +
diff --git a/Content.Server/Materials/MaterialStorageSystem.cs b/Content.Server/Materials/MaterialStorageSystem.cs
index 39284caed19..aa84e2394d2 100644
--- a/Content.Server/Materials/MaterialStorageSystem.cs
+++ b/Content.Server/Materials/MaterialStorageSystem.cs
@@ -143,6 +143,9 @@ public override bool TryInsertMaterialEntity(EntityUid user,
("machine", receiver),
("item", toInsert)),
receiver);
+ if (user != receiver) // Goobstation - for automation to not spam popups
+ _popup.PopupEntity(Loc.GetString("machine-insert-item", ("user", user), ("machine", receiver),
+ ("item", toInsert)), receiver);
QueueDel(toInsert);
// Logging
diff --git a/Content.Server/Physics/Controllers/ConveyorController.cs b/Content.Server/Physics/Controllers/ConveyorController.cs
index b1ebd2fab93..f8312e74bfa 100644
--- a/Content.Server/Physics/Controllers/ConveyorController.cs
+++ b/Content.Server/Physics/Controllers/ConveyorController.cs
@@ -1,6 +1,7 @@
using Content.Server.DeviceLinking.Systems;
using Content.Server.Materials;
using Content.Shared.Conveyor;
+using Content.Shared.DeviceLinking.Events;
using Content.Shared.Destructible;
using Content.Shared.DeviceLinking.Events;
using Content.Shared.Maps;
diff --git a/Content.Server/_Goobstation/Atmos/EntitySystems/GasCanisterSignalSystem.cs b/Content.Server/_Goobstation/Atmos/EntitySystems/GasCanisterSignalSystem.cs
new file mode 100644
index 00000000000..444b105ac4a
--- /dev/null
+++ b/Content.Server/_Goobstation/Atmos/EntitySystems/GasCanisterSignalSystem.cs
@@ -0,0 +1,40 @@
+using Content.Server.Atmos.Piping.Unary.Components;
+using Content.Shared.Atmos.Piping.Binary.Components;
+using Content.Shared.Atmos.Piping.Unary.Components;
+using Content.Shared.DeviceLinking;
+using Content.Shared.DeviceLinking.Events;
+
+namespace Content.Server._Goobstation.Atmos.EntitySystems;
+
+///
+/// Handles control signals for automated gas canisters.
+///
+public sealed class GasCanisterSignalSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnSignalReceived);
+ }
+
+ private void OnSignalReceived(Entity ent, ref SignalReceivedEvent args)
+ {
+ var valve = args.Port switch
+ {
+ "Open" => true,
+ "Close" => false,
+ "Toggle" => !ent.Comp.ReleaseValve,
+ _ => false // fuck you c# cant just return
+ };
+
+ if (ent.Comp.ReleaseValve == valve)
+ return;
+
+ var ev = new GasCanisterChangeReleaseValveMessage(valve);
+ ev.UiKey = GasCanisterUiKey.Key;
+ if (args.Trigger is {} actor)
+ ev.Actor = actor;
+ RaiseLocalEvent(ent, ev);
+ }
+}
diff --git a/Content.Server/_Goobstation/Construction/FlatpackSignalSystem.cs b/Content.Server/_Goobstation/Construction/FlatpackSignalSystem.cs
new file mode 100644
index 00000000000..9f79e53b729
--- /dev/null
+++ b/Content.Server/_Goobstation/Construction/FlatpackSignalSystem.cs
@@ -0,0 +1,28 @@
+using Content.Shared.DeviceLinking.Events;
+using Content.Shared.Construction.Components;
+using Content.Shared.DeviceLinking;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server._Goobstation.Construction;
+
+public sealed class FlatpackSignalSystem : EntitySystem
+{
+ public static readonly ProtoId OnPort = "On";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnSignalReceived);
+ }
+
+ private void OnSignalReceived(Entity ent, ref SignalReceivedEvent args)
+ {
+ if (args.Port != OnPort)
+ return;
+
+ // supercode has no API so we have to do this
+ var ev = new FlatpackCreatorStartPackBuiMessage();
+ RaiseLocalEvent(ent, ev);
+ }
+}
diff --git a/Content.Server/_Goobstation/Disposals/DisposalSignalSystem.cs b/Content.Server/_Goobstation/Disposals/DisposalSignalSystem.cs
new file mode 100644
index 00000000000..b491cb16c59
--- /dev/null
+++ b/Content.Server/_Goobstation/Disposals/DisposalSignalSystem.cs
@@ -0,0 +1,36 @@
+using Content.Shared.DeviceLinking.Events;
+using Content.Server.Power.EntitySystems;
+using Content.Shared.DeviceLinking;
+using Content.Shared.Disposal.Unit;
+using Robust.Shared.Prototypes;
+using Content.Shared.Disposal.Components;
+using Content.Server.Disposal.Unit;
+
+namespace Content.Server._Goobstation.Disposals;
+
+public sealed class DisposalSignalSystem : EntitySystem
+{
+ [Dependency] private readonly DisposalUnitSystem _disposal = default!;
+ [Dependency] private readonly PowerReceiverSystem _power = default!;
+
+ public static readonly ProtoId FlushPort = "DisposalFlush";
+ public static readonly ProtoId EjectPort = "DisposalEject";
+ public static readonly ProtoId TogglePort = "Toggle";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnSignalReceived);
+ }
+
+ private void OnSignalReceived(Entity ent, ref SignalReceivedEvent args)
+ {
+ if (args.Port == FlushPort)
+ _disposal.ToggleEngage(ent, ent);
+ else if (args.Port == EjectPort)
+ _disposal.TryEjectContents(ent, ent);
+ else if (args.Port == TogglePort)
+ _power.TogglePower(ent);
+ }
+}
diff --git a/Content.Server/_Goobstation/Factory/ConstructorSystem.cs b/Content.Server/_Goobstation/Factory/ConstructorSystem.cs
new file mode 100644
index 00000000000..a199800c67a
--- /dev/null
+++ b/Content.Server/_Goobstation/Factory/ConstructorSystem.cs
@@ -0,0 +1,58 @@
+using Content.Shared._Goobstation.Factory;
+using Content.Server.Construction;
+using Content.Shared.Construction.Prototypes;
+using Content.Shared.DoAfter;
+using Robust.Shared.Maths;
+
+namespace Content.Server._Goobstation.Factory;
+
+public sealed class ConstructorSystem : SharedConstructorSystem
+{
+ [Dependency] private readonly ConstructionSystem _construction = default!;
+ [Dependency] private readonly StartableMachineSystem _machine = default!;
+
+ private EntityQuery _activeQuery;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _activeQuery = GetEntityQuery();
+
+ SubscribeLocalEvent(OnStarted);
+ }
+
+ private void OnStarted(Entity ent, ref MachineStartedEvent args)
+ {
+ // can't start if it's already building something
+ if (_activeQuery.HasComp(ent))
+ _machine.Failed(ent.Owner);
+ else
+ Construct(ent);
+ }
+
+ // async because construction shitcode
+ private async void Construct(Entity ent)
+ {
+ var uid = ent.Owner;
+ if (ent.Comp.Construction is not {} id)
+ {
+ _machine.Failed(uid);
+ return;
+ }
+
+ _machine.Started(uid);
+
+ var proto = Proto.Index(id);
+ var completed = proto.Type switch
+ {
+ ConstructionType.Structure => await _construction.TryStartStructureConstruction(uid, id, OutputPosition(ent), Angle.Zero),
+ ConstructionType.Item => await _construction.TryStartItemConstruction(id, uid)
+ };
+
+ if (completed)
+ _machine.Completed(uid);
+ else
+ _machine.Failed(uid);
+ }
+}
diff --git a/Content.Server/_Goobstation/Factory/Filters/PressureFilterSystem.cs b/Content.Server/_Goobstation/Factory/Filters/PressureFilterSystem.cs
new file mode 100644
index 00000000000..15c450a926c
--- /dev/null
+++ b/Content.Server/_Goobstation/Factory/Filters/PressureFilterSystem.cs
@@ -0,0 +1,32 @@
+using Content.Server.Atmos.Components;
+using Content.Server.Atmos.Piping.Unary.Components;
+using Content.Shared._Goobstation.Factory.Filters;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.Piping.Unary.Components;
+
+namespace Content.Server._Goobstation.Factory.Filters;
+
+public sealed class PressureFilterSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnPressureFilter);
+ }
+
+ private void OnPressureFilter(Entity ent, ref AutomationFilterEvent args)
+ {
+ // TODO: replace this shit with InternalAir if it gets refactored
+ float pressure = 0f;
+ if (TryComp(args.Item, out var tank))
+ pressure = tank.Air.Pressure;
+ else if (TryComp(args.Item, out var can))
+ pressure = can.Air.Pressure;
+ else
+ return; // has to be a gas holder
+
+ args.Allowed = pressure >= ent.Comp.Min && pressure <= ent.Comp.Max;
+ args.CouldAllow = true; // pressure can change with a gas canister or if the tank/can valve is opened
+ }
+}
diff --git a/Content.Server/_Goobstation/Factory/InteractorSystem.cs b/Content.Server/_Goobstation/Factory/InteractorSystem.cs
new file mode 100644
index 00000000000..b50a301f4ca
--- /dev/null
+++ b/Content.Server/_Goobstation/Factory/InteractorSystem.cs
@@ -0,0 +1,58 @@
+using Content.Shared._Goobstation.Factory;
+using Content.Server.Construction.Components;
+
+namespace Content.Server._Goobstation.Factory;
+
+public sealed class InteractorSystem : SharedInteractorSystem
+{
+ private EntityQuery _constructionQuery;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _constructionQuery = GetEntityQuery();
+
+ SubscribeLocalEvent(OnStarted);
+ }
+
+ private void OnStarted(Entity ent, ref MachineStartedEvent args)
+ {
+ // nothing there or another doafter is already running
+ var count = ent.Comp.TargetEntities.Count;
+ if (count == 0 || HasDoAfter(ent))
+ {
+ Machine.Failed(ent.Owner);
+ return;
+ }
+
+ var i = count - 1;
+ var netEnt = ent.Comp.TargetEntities[i].Item1;
+ var target = GetEntity(netEnt);
+ _constructionQuery.TryComp(target, out var construction);
+ var originalCount = construction?.InteractionQueue?.Count ?? 0;
+ if (!InteractWith(ent, target))
+ {
+ // have to remove it since user's filter was bad due to unhandled interaction
+ RemoveTarget(ent, target);
+ Machine.Failed(ent.Owner);
+ return;
+ }
+
+ // construction supercode queues it instead of starting a doafter now, assume that queuing means it has started
+ var newCount = construction?.InteractionQueue?.Count ?? 0;
+ if (newCount > originalCount
+ || HasDoAfter(ent))
+ {
+ Machine.Started(ent.Owner);
+ UpdateAppearance(ent, InteractorState.Active);
+ }
+ else
+ {
+ // no doafter, complete it immediately
+ TryRemoveTarget(ent, target);
+ Machine.Completed(ent.Owner);
+ UpdateAppearance(ent);
+ }
+ }
+}
diff --git a/Content.Server/_Goobstation/Fax/FaxSignalSystem.cs b/Content.Server/_Goobstation/Fax/FaxSignalSystem.cs
new file mode 100644
index 00000000000..7e50dd43f42
--- /dev/null
+++ b/Content.Server/_Goobstation/Fax/FaxSignalSystem.cs
@@ -0,0 +1,28 @@
+using Content.Shared.DeviceLinking;
+using Content.Shared.DeviceLinking.Events;
+using Content.Shared.Fax;
+using Content.Shared.Fax.Components;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server._Goobstation.Fax;
+
+///
+/// Handles signals for automated fax machines.
+///
+public sealed class FaxSignalSystem : EntitySystem
+{
+ public static readonly ProtoId CopyPort = "FaxCopy";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnSignalReceived);
+ }
+
+ private void OnSignalReceived(Entity ent, ref SignalReceivedEvent args)
+ {
+ if (args.Port == CopyPort)
+ RaiseLocalEvent(ent, new FaxCopyMessage());
+ }
+}
diff --git a/Content.Server/_Goobstation/Kitchen/MicrowaveEventsSystem.cs b/Content.Server/_Goobstation/Kitchen/MicrowaveEventsSystem.cs
new file mode 100644
index 00000000000..24619a50e2b
--- /dev/null
+++ b/Content.Server/_Goobstation/Kitchen/MicrowaveEventsSystem.cs
@@ -0,0 +1,23 @@
+using Content.Server.Kitchen.Components;
+using Robust.Shared.Containers;
+
+namespace Content.Server._Goobstation.Kitchen;
+
+///
+/// Prevents automation taking items out of an active microwave.
+/// Only exists because microwave supercode only prevents it in interaction, not attempt events.
+///
+public sealed class MicrowaveEventsSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnRemoveAttempt);
+ }
+
+ private void OnRemoveAttempt(Entity ent, ref ContainerIsRemovingAttemptEvent args)
+ {
+ args.Cancel();
+ }
+}
diff --git a/Content.Server/_Goobstation/Singularity/RadCollectorSignalComponent.cs b/Content.Server/_Goobstation/Singularity/RadCollectorSignalComponent.cs
new file mode 100644
index 00000000000..0b2fe0315d8
--- /dev/null
+++ b/Content.Server/_Goobstation/Singularity/RadCollectorSignalComponent.cs
@@ -0,0 +1,21 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Server._Goobstation.Singularity;
+
+///
+/// Emits signals depending on tank pressure for automated radiation collectors.
+///
+[RegisterComponent, Access(typeof(RadCollectorSignalSystem))]
+public sealed partial class RadCollectorSignalComponent : Component
+{
+ [DataField]
+ public RadCollectorState LastState = RadCollectorState.Empty;
+}
+
+[Serializable]
+public enum RadCollectorState : byte
+{
+ Empty,
+ Low,
+ Full
+}
diff --git a/Content.Server/_Goobstation/Singularity/RadCollectorSignalSystem.cs b/Content.Server/_Goobstation/Singularity/RadCollectorSignalSystem.cs
new file mode 100644
index 00000000000..d1f9bef9dc1
--- /dev/null
+++ b/Content.Server/_Goobstation/Singularity/RadCollectorSignalSystem.cs
@@ -0,0 +1,54 @@
+using Content.Shared._Goobstation.Factory;
+using Content.Server.DeviceLinking.Systems;
+using Content.Shared.DeviceLinking;
+using Content.Shared.Singularity.Components;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server._Goobstation.Singularity;
+
+public sealed class RadCollectorSignalSystem : EntitySystem
+{
+ [Dependency] private readonly AutomationSystem _automation = default!;
+ [Dependency] private readonly DeviceLinkSystem _device = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+ public static readonly ProtoId EmptyPort = "RadEmpty";
+ public static readonly ProtoId LowPort = "RadLow";
+ public static readonly ProtoId FullPort = "RadFull";
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (!_automation.IsAutomated(uid))
+ continue;
+
+ var ent = (uid, comp);
+ _appearance.TryGetData(uid, RadiationCollectorVisuals.PressureState, out var rawState);
+ var state = rawState switch
+ {
+ 3 => RadCollectorState.Full,
+ 2 => RadCollectorState.Low,
+ _ => RadCollectorState.Empty
+ };
+
+ // nothing changed
+ if (comp.LastState == state)
+ continue;
+
+ _device.SendSignal(uid, GetPort(comp.LastState), false);
+ comp.LastState = state;
+ _device.SendSignal(uid, GetPort(state), true);
+ }
+ }
+
+ private static string GetPort(RadCollectorState state) => state switch
+ {
+ RadCollectorState.Empty => EmptyPort,
+ RadCollectorState.Low => LowPort,
+ RadCollectorState.Full => FullPort
+ };
+}
diff --git a/Content.Shared/Containers/ItemSlot/ItemSlotsSystem.cs b/Content.Shared/Containers/ItemSlot/ItemSlotsSystem.cs
index e3f1380f402..ca50dcefea4 100644
--- a/Content.Shared/Containers/ItemSlot/ItemSlotsSystem.cs
+++ b/Content.Shared/Containers/ItemSlot/ItemSlotsSystem.cs
@@ -144,7 +144,7 @@ public bool TryGetSlot(EntityUid uid,
{
itemSlot = null;
- if (!Resolve(uid, ref component))
+ if (!Resolve(uid, ref component, false)) // Goobstation - sane API
return false;
return component.Slots.TryGetValue(slotId, out itemSlot);
diff --git a/Content.Shared/DeviceLinking/SharedDeviceLinkSystem.cs b/Content.Shared/DeviceLinking/SharedDeviceLinkSystem.cs
index c9a3a6260f3..17ba3f84085 100644
--- a/Content.Shared/DeviceLinking/SharedDeviceLinkSystem.cs
+++ b/Content.Shared/DeviceLinking/SharedDeviceLinkSystem.cs
@@ -206,6 +206,32 @@ public string PortName(string port) where TPort : DevicePortPrototype, IP
return Loc.GetString(proto.Name);
}
+
+ ///
+ /// Goobstation - Removes a port from a source.
+ ///
+ public void RemoveSourcePort(EntityUid uid, ProtoId port)
+ {
+ if (!TryComp(uid, out var comp))
+ return;
+
+ comp.Ports.Remove(port);
+ if (comp.Ports.Count == 0)
+ RemCompDeferred(uid);
+ }
+
+ ///
+ /// Goobstation - Removes a port from a sink.
+ ///
+ public void RemoveSinkPort(EntityUid uid, ProtoId port)
+ {
+ if (!TryComp(uid, out var comp))
+ return;
+
+ comp.Ports.Remove(port);
+ if (comp.Ports.Count == 0)
+ RemCompDeferred(uid);
+ }
#endregion
#region Links
diff --git a/Content.Shared/Disposal/Unit/SharedDisposalUnitSystem.cs b/Content.Shared/Disposal/Unit/SharedDisposalUnitSystem.cs
index c71e71061d5..29210ecdcda 100644
--- a/Content.Shared/Disposal/Unit/SharedDisposalUnitSystem.cs
+++ b/Content.Shared/Disposal/Unit/SharedDisposalUnitSystem.cs
@@ -4,6 +4,7 @@
using Content.Shared.Climbing.Systems;
using Content.Shared.Containers;
using Content.Shared.Database;
+using Content.Shared.DeviceLinking; // Goobstation
using Content.Shared.Disposal.Components;
using Content.Shared.Disposal.Unit.Events;
using Content.Shared.DoAfter;
@@ -31,6 +32,7 @@
using Robust.Shared.Physics.Systems;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
+using Robust.Shared.Prototypes; // Goobstation
using Robust.Shared.Utility;
namespace Content.Shared.Disposal.Unit;
@@ -60,6 +62,8 @@ public abstract class SharedDisposalUnitSystem : EntitySystem
[Dependency] protected readonly SharedTransformSystem TransformSystem = default!;
[Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
[Dependency] private readonly SharedMapSystem _map = default!;
+ [Dependency] private readonly SharedDeviceLinkSystem _device = default!; // Goobstation
+ public static readonly ProtoId ReadyPort = "DisposalReady"; // Goobstation
protected static TimeSpan ExitAttemptDelay = TimeSpan.FromSeconds(0.5);
@@ -552,6 +556,7 @@ private void UpdateState(EntityUid uid, DisposalsPressureState state, DisposalUn
if (state == DisposalsPressureState.Ready)
{
component.NextPressurized = TimeSpan.Zero;
+ _device.InvokePort(uid, ReadyPort); // Goobstation
// Manually engaged
if (component.Engaged)
diff --git a/Content.Shared/DoAfter/DoAfterComponent.cs b/Content.Shared/DoAfter/DoAfterComponent.cs
index 537d8c2e937..4d3ac372cf4 100644
--- a/Content.Shared/DoAfter/DoAfterComponent.cs
+++ b/Content.Shared/DoAfter/DoAfterComponent.cs
@@ -20,6 +20,12 @@ public sealed partial class DoAfterComponent : Component
[DataField(readOnly:true)]
public Dictionary DoAfters = new();
+ ///
+ /// Goobstation - Whether to raise DoAfterEndedEvent on the user after it ends.
+ ///
+ [DataField]
+ public bool RaiseEndedEvent;
+
// Used by obsolete async do afters
public readonly Dictionary> AwaitedDoAfters = new();
}
diff --git a/Content.Shared/DoAfter/SharedDoAfterSystem.cs b/Content.Shared/DoAfter/SharedDoAfterSystem.cs
index 71ef35c4618..4af089c757d 100644
--- a/Content.Shared/DoAfter/SharedDoAfterSystem.cs
+++ b/Content.Shared/DoAfter/SharedDoAfterSystem.cs
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
+using Content.Shared._Goobstation.DoAfter; // Goobstation
using Content.Shared.ActionBlocker;
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
@@ -89,6 +90,15 @@ private void RaiseDoAfterEvents(DoAfter doAfter, DoAfterComponent component)
else if (doAfter.Args.Broadcast)
RaiseLocalEvent((object)ev);
+ //
+ if (component.RaiseEndedEvent
+ && Exists(doAfter.Args.User))
+ {
+ var ended = new DoAfterEndedEvent(doAfter.Args.Target, doAfter.Cancelled);
+ RaiseLocalEvent(doAfter.Args.User, ref ended);
+ }
+ //
+
if (component.AwaitedDoAfters.Remove(doAfter.Index, out var tcs))
tcs.SetResult(doAfter.Cancelled ? DoAfterStatus.Cancelled : DoAfterStatus.Finished);
}
diff --git a/Content.Shared/Interaction/SharedInteractionSystem.cs b/Content.Shared/Interaction/SharedInteractionSystem.cs
index be8a54ea609..ab7b773304a 100644
--- a/Content.Shared/Interaction/SharedInteractionSystem.cs
+++ b/Content.Shared/Interaction/SharedInteractionSystem.cs
@@ -29,6 +29,7 @@
using Content.Shared.UserInterface;
using Content.Shared.Verbs;
using Content.Shared.Wall;
+using Content.Shared._Goobstation.DoAfter; // Goobstation
using JetBrains.Annotations;
using Robust.Shared.Containers;
using Robust.Shared.Input;
@@ -495,22 +496,21 @@ private bool IsDeleted(EntityUid? uid)
return uid != null && IsDeleted(uid.Value);
}
- public void InteractHand(EntityUid user, EntityUid target)
+ public bool InteractHand(EntityUid user, EntityUid target) // Goobstation - useful return value
{
if (IsDeleted(user) || IsDeleted(target))
- return;
+ return false; // Goobstation
var complexInteractions = _actionBlockerSystem.CanComplexInteract(user);
if (!complexInteractions)
{
- InteractionActivate(user,
+ return InteractionActivate(user, // Goobstation
target,
checkCanInteract: false,
checkUseDelay: true,
checkAccess: false,
complexInteractions: complexInteractions,
checkDeletion: false);
- return;
}
// allow for special logic before main interaction
@@ -519,7 +519,7 @@ public void InteractHand(EntityUid user, EntityUid target)
if (ev.Handled)
{
_adminLogger.Add(LogType.InteractHand, LogImpact.Low, $"{ToPrettyString(user):user} interacted with {ToPrettyString(target):target}, but it was handled by another system");
- return;
+ return false; // Goobstation
}
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(target));
@@ -533,11 +533,11 @@ public void InteractHand(EntityUid user, EntityUid target)
_adminLogger.Add(LogType.InteractHand, LogImpact.Low, $"{user} interacted with {target}");
DoContactInteraction(user, target, message);
if (message.Handled || userMessage.Handled)
- return;
+ return true; // Goobstation
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(target));
// Else we run Activate.
- InteractionActivate(user,
+ return InteractionActivate(user, // Goobstation
target,
checkCanInteract: false,
checkUseDelay: true,
diff --git a/Content.Shared/Lathe/LatheComponent.cs b/Content.Shared/Lathe/LatheComponent.cs
index 1dd2fb17bbd..f95636c11d8 100644
--- a/Content.Shared/Lathe/LatheComponent.cs
+++ b/Content.Shared/Lathe/LatheComponent.cs
@@ -73,7 +73,7 @@ public sealed partial class LatheComponent : Component
///
/// A modifier that changes how long it takes to print a recipe
///
- [DataField, ViewVariables(VVAccess.ReadWrite)]
+ [DataField, ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
public float TimeMultiplier = 1;
///
diff --git a/Content.Shared/Mobs/Systems/MobThresholdSystem.cs b/Content.Shared/Mobs/Systems/MobThresholdSystem.cs
index 8442268df11..72d7b1f71dd 100644
--- a/Content.Shared/Mobs/Systems/MobThresholdSystem.cs
+++ b/Content.Shared/Mobs/Systems/MobThresholdSystem.cs
@@ -125,7 +125,7 @@ public bool TryGetThresholdForState(EntityUid target, MobState mobState,
MobThresholdsComponent? thresholdComponent = null)
{
threshold = null;
- if (!Resolve(target, ref thresholdComponent))
+ if (!Resolve(target, ref thresholdComponent, false)) // Goobstation
return false;
foreach (var pair in thresholdComponent.Thresholds)
diff --git a/Content.Shared/Stacks/SharedStackSystem.cs b/Content.Shared/Stacks/SharedStackSystem.cs
index e69bcb24421..062b6ca69a3 100644
--- a/Content.Shared/Stacks/SharedStackSystem.cs
+++ b/Content.Shared/Stacks/SharedStackSystem.cs
@@ -7,6 +7,7 @@
using Content.Shared.Verbs;
using JetBrains.Annotations;
using Robust.Shared.GameStates;
+using Robust.Shared.Map; // Goobstation
using Robust.Shared.Physics.Systems;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
@@ -108,6 +109,15 @@ private void OnStackInteractUsing(Entity ent, ref InteractUsingE
_storage.PlayPickupAnimation(args.Used, popupPos, userCoords, localRotation, args.User);
}
+ ///
+ /// Goobstation - virtual method to allow calling from shared.
+ /// Does nothing on the client.
+ ///
+ public virtual EntityUid? Split(Entity ent, int amount, EntityCoordinates spawnPosition)
+ {
+ return null;
+ }
+
private void OnStackStarted(Entity ent, ref ComponentStartup args)
{
if (!TryComp(ent.Owner, out AppearanceComponent? appearance))
@@ -255,4 +265,4 @@ public StackSplitRequestEvent(NetEntity netEnt, int amount)
Stack = netEnt;
Amount = amount;
}
-}
\ No newline at end of file
+}
diff --git a/Content.Shared/_DV/Construction/UpgradeKitComponent.cs b/Content.Shared/_DV/Construction/UpgradeKitComponent.cs
new file mode 100644
index 00000000000..4cb2ce895f8
--- /dev/null
+++ b/Content.Shared/_DV/Construction/UpgradeKitComponent.cs
@@ -0,0 +1,51 @@
+using Content.Shared.DoAfter;
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._DV.Construction;
+
+///
+/// Component for an upgrade kit that upgrades allowed machines then deletes itself.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(UpgradeKitSystem))]
+public sealed partial class UpgradeKitComponent : Component
+{
+ ///
+ /// A whitelist that entities must match to be upgraded.
+ ///
+ [DataField(required: true)]
+ public EntityWhitelist Whitelist = new();
+
+ ///
+ /// A blacklist that entities cannot match to be upgraded.
+ ///
+ [DataField(required: true)]
+ public EntityWhitelist Blacklist = new();
+
+ ///
+ /// Components added to the machine after it's upgraded.
+ /// Some of these must blacklist it from upgrades to prevent stacking.
+ ///
+ [DataField(required: true)]
+ public ComponentRegistry Components = new();
+
+ ///
+ /// How long the doafter is
+ ///
+ [DataField]
+ public TimeSpan Delay = TimeSpan.FromSeconds(4);
+
+ ///
+ /// Sound played when upgrading an entity.
+ ///
+ [DataField]
+ public SoundSpecifier? UpgradeSound = new SoundPathSpecifier("/Audio/Items/rped.ogg");
+
+ public EntityUid? SoundStream;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class UpgradeKitDoAfterEvent : SimpleDoAfterEvent;
diff --git a/Content.Shared/_DV/Construction/UpgradeKitSystem.cs b/Content.Shared/_DV/Construction/UpgradeKitSystem.cs
new file mode 100644
index 00000000000..bb1b75ce8fa
--- /dev/null
+++ b/Content.Shared/_DV/Construction/UpgradeKitSystem.cs
@@ -0,0 +1,89 @@
+using Content.Shared.DoAfter;
+using Content.Shared.Interaction;
+using Content.Shared.Popups;
+using Content.Shared.Whitelist;
+using Content.Shared.Wires;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Network;
+
+namespace Content.Shared._DV.Construction;
+
+///
+/// Handles upgrading machines using upgrade kits.
+///
+public sealed class UpgradeKitSystem : EntitySystem
+{
+ [Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
+ [Dependency] private readonly INetManager _net = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedWiresSystem _wires = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnAfterInteract);
+ SubscribeLocalEvent(OnDoAfter);
+ }
+
+ private void OnAfterInteract(Entity ent, ref AfterInteractEvent args)
+ {
+ if (args.Handled || !args.CanReach || args.Target is not {} target)
+ return;
+
+ args.Handled = true;
+
+ var user = args.User;
+ if (!CanUpgrade(ent, target, user))
+ return;
+
+ if (!_wires.IsPanelOpen(target))
+ {
+ _popup.PopupClient(Loc.GetString("construction-step-condition-wire-panel-open"), target, user);
+ return;
+ }
+
+ ent.Comp.SoundStream = _audio.PlayPredicted(ent.Comp.UpgradeSound, ent, user)?.Entity;
+ Dirty(ent);
+ var ev = new UpgradeKitDoAfterEvent();
+ _doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager, user, ent.Comp.Delay, ev, ent, target, ent));
+ }
+
+ private void OnDoAfter(Entity ent, ref UpgradeKitDoAfterEvent args)
+ {
+ ent.Comp.SoundStream = _audio.Stop(ent.Comp.SoundStream);
+ if (args.Cancelled)
+ return;
+
+ if (args.Handled || args.Args.Target is not {} target)
+ return;
+
+ args.Handled = true;
+
+ var user = args.Args.User;
+ if (!CanUpgrade(ent, target, user))
+ return;
+
+ // do the upgrading now
+ EntityManager.AddComponents(target, ent.Comp.Components);
+ if (_net.IsServer)
+ QueueDel(ent);
+ }
+
+ ///
+ /// Check the upgrade kit's whitelist and blacklist, showing a popup if it is invalid.
+ ///
+ public bool CanUpgrade(Entity ent, EntityUid target, EntityUid user)
+ {
+ if (_whitelist.IsWhitelistFail(ent.Comp.Whitelist, target) ||
+ _whitelist.IsWhitelistPass(ent.Comp.Blacklist, target)) // Art-change
+ {
+ _popup.PopupClient(Loc.GetString("upgrade-kit-invalid-target"), target, user);
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/Content.Shared/_DV/Construction/UpgradedMachineComponent.cs b/Content.Shared/_DV/Construction/UpgradedMachineComponent.cs
new file mode 100644
index 00000000000..095ff0da93c
--- /dev/null
+++ b/Content.Shared/_DV/Construction/UpgradedMachineComponent.cs
@@ -0,0 +1,17 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._DV.Construction;
+
+///
+/// Component added to machines to prevent stacking upgrades and show what upgrade they have.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(UpgradedMachineSystem))]
+[AutoGenerateComponentState]
+public sealed partial class UpgradedMachineComponent : Component
+{
+ ///
+ /// The string to show when examined.
+ ///
+ [DataField(required: true), AutoNetworkedField]
+ public LocId Upgrade;
+}
diff --git a/Content.Shared/_DV/Construction/UpgradedMachineSystem.cs b/Content.Shared/_DV/Construction/UpgradedMachineSystem.cs
new file mode 100644
index 00000000000..9cd65c8313e
--- /dev/null
+++ b/Content.Shared/_DV/Construction/UpgradedMachineSystem.cs
@@ -0,0 +1,21 @@
+using Content.Shared.Examine;
+
+namespace Content.Shared._DV.Construction;
+
+public sealed class UpgradedMachineSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnExamined);
+ }
+
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ args.PushMarkup(Loc.GetString(ent.Comp.Upgrade));
+ }
+}
diff --git a/Content.Shared/_DV/Lathe/LatheUpgradeComponent.cs b/Content.Shared/_DV/Lathe/LatheUpgradeComponent.cs
new file mode 100644
index 00000000000..0414ed0d619
--- /dev/null
+++ b/Content.Shared/_DV/Lathe/LatheUpgradeComponent.cs
@@ -0,0 +1,20 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._DV.Lathe;
+
+///
+/// Any non-null fields get copied onto LatheComponent at MapInit.
+/// Gets removed from the entity after its work is done.
+///
+///
+/// Only exists because ComponentRegistry / AddComponent bulldozes existing fields unlike prototype composition.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(LatheUpgradeSystem))]
+public sealed partial class LatheUpgradeComponent : Component
+{
+ [DataField]
+ public float? TimeMultiplier;
+
+ [DataField]
+ public float? MaterialUseMultiplier;
+}
diff --git a/Content.Shared/_DV/Lathe/LatheUpgradeSystem.cs b/Content.Shared/_DV/Lathe/LatheUpgradeSystem.cs
new file mode 100644
index 00000000000..fc111f81bb6
--- /dev/null
+++ b/Content.Shared/_DV/Lathe/LatheUpgradeSystem.cs
@@ -0,0 +1,31 @@
+using Content.Shared.Lathe;
+
+namespace Content.Shared._DV.Lathe;
+
+///
+/// Applies modifiers when added to a lathe and removes it.
+///
+public sealed class LatheUpgradeSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMapInit);
+ }
+
+ private void OnMapInit(Entity ent, ref MapInitEvent args)
+ {
+ RemCompDeferred(ent);
+
+ if (!TryComp(ent, out var lathe))
+ return;
+
+ if (ent.Comp.MaterialUseMultiplier is {} matMul)
+ lathe.MaterialUseMultiplier = matMul;
+ if (ent.Comp.TimeMultiplier is {} timeMul)
+ lathe.TimeMultiplier = timeMul;
+
+ Dirty(ent, lathe);
+ }
+}
diff --git a/Content.Shared/_Goobstation/Construction/ConstructedEvent.cs b/Content.Shared/_Goobstation/Construction/ConstructedEvent.cs
new file mode 100644
index 00000000000..29477ed52f7
--- /dev/null
+++ b/Content.Shared/_Goobstation/Construction/ConstructedEvent.cs
@@ -0,0 +1,7 @@
+namespace Content.Shared._Goobstation.Construction;
+
+///
+/// Raised on the user after an entity is created by construction.
+///
+[ByRefEvent]
+public readonly record struct ConstructedEvent(EntityUid Entity);
diff --git a/Content.Shared/_Goobstation/DoAfter/DoAfterEndedEvent.cs b/Content.Shared/_Goobstation/DoAfter/DoAfterEndedEvent.cs
new file mode 100644
index 00000000000..5fd281a872a
--- /dev/null
+++ b/Content.Shared/_Goobstation/DoAfter/DoAfterEndedEvent.cs
@@ -0,0 +1,7 @@
+namespace Content.Shared._Goobstation.DoAfter;
+
+///
+/// Event raised on the doafter user after a doafter ends.
+///
+[ByRefEvent]
+public readonly record struct DoAfterEndedEvent(EntityUid? Target, bool Cancelled);
diff --git a/Content.Shared/_Goobstation/Factory/AutomatedComponent.cs b/Content.Shared/_Goobstation/Factory/AutomatedComponent.cs
new file mode 100644
index 00000000000..338a5bd1b7e
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/AutomatedComponent.cs
@@ -0,0 +1,10 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Goobstation.Factory;
+
+///
+/// Component added to machines with to enable their ports for linking.
+/// They can then be automated with things like a .
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class AutomatedComponent : Component;
diff --git a/Content.Shared/_Goobstation/Factory/AutomationSlotsComponent.cs b/Content.Shared/_Goobstation/Factory/AutomationSlotsComponent.cs
new file mode 100644
index 00000000000..8d64850f910
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/AutomationSlotsComponent.cs
@@ -0,0 +1,18 @@
+using Content.Shared._Goobstation.Factory.Slots;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Goobstation.Factory;
+
+///
+/// Adds slots to an entity that can be controlled by automation machines if it also has .
+/// Slots using can provide or accept items.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(AutomationSystem))]
+public sealed partial class AutomationSlotsComponent : Component
+{
+ ///
+ /// All input slots that can be automated.
+ ///
+ [DataField(required: true)]
+ public List Slots = new();
+}
diff --git a/Content.Shared/_Goobstation/Factory/AutomationSystem.cs b/Content.Shared/_Goobstation/Factory/AutomationSystem.cs
new file mode 100644
index 00000000000..8deb6ce9b9e
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/AutomationSystem.cs
@@ -0,0 +1,134 @@
+using Content.Shared._Goobstation.Factory.Slots;
+using Content.Shared.Prototypes;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Physics.Systems;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._Goobstation.Factory;
+
+public sealed class AutomationSystem : EntitySystem
+{
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly SharedPhysicsSystem _physics = default!;
+
+ private EntityQuery _slotsQuery;
+ private EntityQuery _automatedQuery;
+
+ private List _automatable = new();
+ ///
+ /// All entities with , maintained on prototype reload.
+ ///
+ public IReadOnlyList Automatable => _automatable;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _slotsQuery = GetEntityQuery();
+ _automatedQuery = GetEntityQuery();
+
+ SubscribeLocalEvent(OnInit);
+
+ SubscribeLocalEvent(OnMapInit);
+ SubscribeLocalEvent(OnShutdown);
+
+ SubscribeLocalEvent(OnAnchorChanged);
+
+ SubscribeLocalEvent(OnPrototypesReloaded);
+ CacheEntities();
+ }
+
+ private void OnInit(Entity ent, ref ComponentInit args)
+ {
+ foreach (var slot in ent.Comp.Slots)
+ {
+ slot.Owner = ent;
+ slot.Initialize();
+ }
+ }
+
+ private void OnMapInit(Entity ent, ref MapInitEvent args)
+ {
+ if (!TryComp(ent, out var comp))
+ return;
+
+ foreach (var slot in comp.Slots)
+ {
+ slot.AddPorts();
+ }
+ }
+
+ private void OnShutdown(Entity ent, ref ComponentShutdown args)
+ {
+ if (!TryComp(ent, out var comp))
+ return;
+
+ foreach (var slot in comp.Slots)
+ {
+ slot.RemovePorts();
+ }
+ }
+
+ private void OnAnchorChanged(Entity ent, ref AnchorStateChangedEvent args)
+ {
+ // force collision events so machines can react to objects getting unanchored
+ // should get reset after a tick due to collision wake
+ if (!args.Anchored)
+ _physics.WakeBody(ent);
+ }
+
+ private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
+ {
+ if (!args.WasModified())
+ return;
+
+ CacheEntities();
+ }
+
+ private void CacheEntities()
+ {
+ _automatable.Clear();
+ var factory = EntityManager.ComponentFactory;
+ foreach (var proto in _proto.EnumeratePrototypes())
+ {
+ if (proto.HasComponent(factory))
+ _automatable.Add(proto.ID);
+ }
+
+ _automatable.Sort();
+ }
+
+ #region Public API
+
+ public AutomationSlot? GetSlot(Entity ent, string port, bool input)
+ {
+ // entity has no automation slots to begin with
+ if (!_slotsQuery.Resolve(ent, ref ent.Comp, false))
+ return null;
+
+ // automation isn't enabled
+ if (!IsAutomated(ent))
+ return null;
+
+ foreach (var slot in ent.Comp.Slots)
+ {
+ string? id = input ? slot.Input : slot.Output;
+ if (id == port)
+ return slot;
+ }
+
+ return null;
+ }
+
+ public bool IsAutomated(EntityUid uid)
+ {
+ return _automatedQuery.HasComp(uid);
+ }
+
+ public bool HasSlot(Entity ent, string port, bool input)
+ {
+ return GetSlot(ent, port, input) != null;
+ }
+
+ #endregion
+}
diff --git a/Content.Shared/_Goobstation/Factory/ConstructorComponent.cs b/Content.Shared/_Goobstation/Factory/ConstructorComponent.cs
new file mode 100644
index 00000000000..adf66bd0247
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/ConstructorComponent.cs
@@ -0,0 +1,33 @@
+using Content.Shared.Construction.Prototypes;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Goobstation.Factory;
+
+///
+/// Machine that starts constructions.
+/// Multi-step objects will need interactors to complete their steps.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedConstructorSystem))]
+[AutoGenerateComponentState]
+public sealed partial class ConstructorComponent : Component
+{
+ ///
+ /// The construction it will try to build when start is invoked.
+ ///
+ [DataField, AutoNetworkedField]
+ public ProtoId? Construction;
+}
+
+[Serializable, NetSerializable]
+public enum ConstructorUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed class ConstructorSetProtoMessage(ProtoId? id) : BoundUserInterfaceMessage
+{
+ public ProtoId? Id = id;
+}
diff --git a/Content.Shared/_Goobstation/Factory/Filters/AutomationFilterComponent.cs b/Content.Shared/_Goobstation/Factory/Filters/AutomationFilterComponent.cs
new file mode 100644
index 00000000000..60e62e37d27
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/Filters/AutomationFilterComponent.cs
@@ -0,0 +1,23 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Goobstation.Factory.Filters;
+
+///
+/// Marker component for filter items.
+/// Only used for whitelisting, does nothing on its own.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class AutomationFilterComponent : Component;
+
+///
+/// Event raised on a filter to determine if it should block an item.
+/// If CouldAllow is set to true, IsAlwaysBlocked will return false.
+///
+[ByRefEvent]
+public record struct AutomationFilterEvent(EntityUid Item, bool Allowed = false, bool CouldAllow = false);
+
+///
+/// Event raised on a filter to get its stack split size.
+///
+[ByRefEvent]
+public record struct AutomationFilterSplitEvent(int Size = 0);
diff --git a/Content.Shared/_Goobstation/Factory/Filters/AutomationFilterSystem.cs b/Content.Shared/_Goobstation/Factory/Filters/AutomationFilterSystem.cs
new file mode 100644
index 00000000000..61d30c67eed
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/Filters/AutomationFilterSystem.cs
@@ -0,0 +1,383 @@
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.DeviceLinking;
+using Content.Shared.Examine;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Labels.Components;
+using Content.Shared.Popups;
+using Content.Shared.Stacks;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._Goobstation.Factory.Filters;
+
+public sealed class AutomationFilterSystem : EntitySystem
+{
+ [Dependency] private readonly ItemSlotsSystem _slots = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedStackSystem _stack = default!;
+
+ private EntityQuery _slotQuery;
+ private EntityQuery _labelQuery;
+ private EntityQuery _stackQuery;
+
+ public static readonly int GateCount = Enum.GetValues(typeof(LogicGate)).Length;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _slotQuery = GetEntityQuery();
+ _labelQuery = GetEntityQuery();
+ _stackQuery = GetEntityQuery();
+
+ Subs.BuiEvents(LabelFilterUiKey.Key, subs =>
+ {
+ subs.Event(OnLabelSet);
+ });
+ SubscribeLocalEvent(OnLabelExamined);
+ SubscribeLocalEvent(OnLabelFilter);
+
+ Subs.BuiEvents(NameFilterUiKey.Key, subs =>
+ {
+ subs.Event(OnNameSet);
+ subs.Event(OnNameSetMode);
+ });
+ SubscribeLocalEvent(OnNameExamined);
+ SubscribeLocalEvent(OnNameFilter);
+
+ Subs.BuiEvents(StackFilterUiKey.Key, subs =>
+ {
+ subs.Event(OnStackSetMin);
+ subs.Event(OnStackSetSize);
+ });
+ SubscribeLocalEvent(OnStackExamined);
+ SubscribeLocalEvent(OnStackFilter);
+ SubscribeLocalEvent(OnStackSplit);
+
+ SubscribeLocalEvent(OnCombinedInit);
+ SubscribeLocalEvent(OnCombinedUse);
+ SubscribeLocalEvent(OnCombinedExamined);
+ SubscribeLocalEvent(OnCombinedFilter);
+ SubscribeLocalEvent(OnCombinedSplit);
+
+ Subs.BuiEvents(PressureFilterUiKey.Key, subs =>
+ {
+ subs.Event(OnPressureSetMin);
+ subs.Event(OnPressureSetMax);
+ });
+ SubscribeLocalEvent(OnPressureExamined);
+ // OnPressureFilter is in server because atmos is serverside
+
+ SubscribeLocalEvent(OnSlotInit);
+ }
+
+ /* Label filter */
+
+ private void OnLabelSet(Entity ent, ref LabelFilterSetLabelMessage args)
+ {
+ var label = args.Label.Trim();
+ if (label.Length > ent.Comp.MaxLength)
+ return;
+
+ ent.Comp.Label = label;
+ Dirty(ent);
+ }
+
+ private void OnLabelExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ if (string.IsNullOrEmpty(ent.Comp.Label))
+ {
+ args.PushMarkup(Loc.GetString("automation-filter-examine-empty"));
+ return;
+ }
+
+ args.PushText(Loc.GetString("automation-filter-examine-string", ("name", ent.Comp.Label)));
+ }
+
+ private void OnLabelFilter(Entity ent, ref AutomationFilterEvent args)
+ {
+ args.Allowed = _labelQuery.CompOrNull(args.Item)?.CurrentLabel == ent.Comp.Label;
+ args.CouldAllow = true; // hand labelers can change the label
+ }
+
+ /* Name filter */
+
+ private void OnNameSet(Entity ent, ref NameFilterSetNameMessage args)
+ {
+ var name = args.Name.Trim();
+ if (name.Length > ent.Comp.MaxLength || ent.Comp.Name == name)
+ return;
+
+ ent.Comp.Name = name;
+ Dirty(ent);
+ }
+
+ private void OnNameSetMode(Entity ent, ref NameFilterSetModeMessage args)
+ {
+ if (ent.Comp.Mode == args.Mode)
+ return;
+
+ ent.Comp.Mode = args.Mode;
+ Dirty(ent);
+ }
+
+ private void OnNameExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ if (string.IsNullOrEmpty(ent.Comp.Name))
+ {
+ args.PushMarkup(Loc.GetString("automation-filter-examine-empty"));
+ return;
+ }
+
+ args.PushText(Loc.GetString("automation-filter-examine-string", ("name", ent.Comp.Name)));
+ }
+
+ private void OnNameFilter(Entity ent, ref AutomationFilterEvent args)
+ {
+ var name = Name(args.Item);
+ var check = ent.Comp.Name;
+ args.Allowed = ent.Comp.Mode switch
+ {
+ NameFilterMode.Contain => name.Contains(check),
+ NameFilterMode.Start => name.StartsWith(check),
+ NameFilterMode.End => name.EndsWith(check),
+ NameFilterMode.Match => name == check
+ };
+ // entity names usually don't change except for the end including a label
+ args.CouldAllow = ent.Comp.Mode switch
+ {
+ NameFilterMode.End | NameFilterMode.Match => true,
+ _ => false
+ };
+ }
+
+ /* Stack filter */
+
+ private void OnStackSetMin(Entity ent, ref StackFilterSetMinMessage args)
+ {
+ if (args.Min < 1 || ent.Comp.Min == args.Min)
+ return;
+
+ ent.Comp.Min = args.Min;
+ Dirty(ent);
+ }
+
+ private void OnStackSetSize(Entity ent, ref StackFilterSetSizeMessage args)
+ {
+ if (args.Size < 0 || ent.Comp.Size == args.Size)
+ return;
+
+ ent.Comp.Size = args.Size;
+ Dirty(ent);
+ }
+
+ private void OnStackExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ args.PushMarkup(Loc.GetString("stack-filter-examine", ("size", ent.Comp.Size)));
+ }
+
+ private void OnStackFilter(Entity ent, ref AutomationFilterEvent args)
+ {
+ args.Allowed = _stackQuery.CompOrNull(args.Item)?.Count >= ent.Comp.Min;
+ args.CouldAllow = true;
+ }
+
+ private void OnStackSplit(Entity ent, ref AutomationFilterSplitEvent args)
+ {
+ args.Size = ent.Comp.Size;
+ }
+
+ /* Combined filter */
+
+ private void OnCombinedInit(Entity ent, ref ComponentInit args)
+ {
+ if (!TryComp(ent, out var slots))
+ return;
+
+ if (!_slots.TryGetSlot(ent, CombinedFilterComponent.FilterAName, out var filterA, slots) ||
+ !_slots.TryGetSlot(ent, CombinedFilterComponent.FilterBName, out var filterB, slots))
+ {
+ Log.Error($"{ToPrettyString(ent)} was missing filter slots!");
+ RemCompDeferred(ent);
+ return;
+ }
+
+ ent.Comp.FilterA = filterA;
+ ent.Comp.FilterB = filterB;
+ }
+
+ private void OnCombinedUse(Entity ent, ref UseInHandEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ args.Handled = true;
+
+ var gate = (int) ent.Comp.Gate;
+ gate = ++gate % GateCount;
+ ent.Comp.Gate = (LogicGate) gate;
+ Dirty(ent);
+
+ var msg = Loc.GetString("logic-gate-cycle", ("gate", ent.Comp.Gate.ToString().ToUpper()));
+ _popup.PopupClient(msg, ent, args.User);
+ }
+
+ private void OnCombinedExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ args.PushMarkup(Loc.GetString("combined-filter-examine", ("gate", ent.Comp.Gate.ToString().ToUpper())));
+ }
+
+ private void OnCombinedFilter(Entity ent, ref AutomationFilterEvent args)
+ {
+ var a = IsAllowed(ent.Comp.FilterA.Item, args.Item, out var couldAllowA);
+ var b = IsAllowed(ent.Comp.FilterB.Item, args.Item, out var couldAllowB);
+ args.Allowed = ent.Comp.Gate switch
+ {
+ LogicGate.Or => a || b,
+ LogicGate.And => a && b,
+ LogicGate.Xor => a != b,
+ LogicGate.Nor => !(a || b),
+ LogicGate.Nand => !(a && b),
+ LogicGate.Xnor => a == b
+ };
+ args.CouldAllow = couldAllowA || couldAllowB; // if any subfilter could allow it, this could allow it too
+ }
+
+ private void OnCombinedSplit(Entity ent, ref AutomationFilterSplitEvent args)
+ {
+ var a = GetSplitSize(ent.Comp.FilterA.Item);
+ var b = GetSplitSize(ent.Comp.FilterB.Item);
+ args.Size = Math.Max(a, b);
+ }
+
+ /* Pressure filter */
+
+ private void OnPressureSetMin(Entity ent, ref PressureFilterSetMinMessage args)
+ {
+ var min = args.Min;
+ if (min == ent.Comp.Min || min > ent.Comp.Max || min < 0f)
+ return;
+
+ ent.Comp.Min = min;
+ Dirty(ent);
+ }
+
+ private void OnPressureSetMax(Entity ent, ref PressureFilterSetMaxMessage args)
+ {
+ var max = args.Max;
+ if (max == ent.Comp.Max || max < ent.Comp.Min)
+ return;
+
+ ent.Comp.Max = max;
+ Dirty(ent);
+ }
+
+ private void OnPressureExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ args.PushMarkup(Loc.GetString("pressure-filter-examine", ("min", ent.Comp.Min), ("max", ent.Comp.Max)));
+ }
+
+ /* Filter slot */
+
+ private void OnSlotInit(Entity ent, ref ComponentInit args)
+ {
+ if (!TryComp(ent, out var slots))
+ return;
+
+ if (!_slots.TryGetSlot(ent, ent.Comp.FilterSlotId, out var filterSlot, slots))
+ {
+ Log.Warning($"Missing filter slot {ent.Comp.FilterSlotId} on {ToPrettyString(ent)}");
+ RemCompDeferred(ent);
+ return;
+ }
+
+ ent.Comp.FilterSlot = filterSlot;
+ }
+
+ #region Public API
+ ///
+ /// Returns true if an item is allowed by the filter, false if it's blocked.
+ /// If there is no filter, items are always allowed.
+ ///
+ public bool IsAllowed(EntityUid? filter, EntityUid item, out bool couldAllow)
+ {
+ couldAllow = false;
+ if (filter is not {} uid)
+ return true;
+
+ var ev = new AutomationFilterEvent(item);
+ RaiseLocalEvent(uid, ref ev);
+ couldAllow = ev.CouldAllow;
+ return ev.Allowed;
+ }
+
+ public bool IsAllowed(EntityUid? filter, EntityUid item) => IsAllowed(filter, item, out _);
+
+ ///
+ /// Inverse of .
+ ///
+ public bool IsBlocked(EntityUid? filter, EntityUid item, out bool couldAllow) => !IsAllowed(filter, item, out couldAllow);
+
+ public bool IsBlocked(EntityUid? filter, EntityUid item) => IsBlocked(filter, item, out _);
+
+ ///
+ /// Returns true if an item can never be allowed by a filter, even if some data about it changes.
+ ///
+ public bool IsAlwaysBlocked(EntityUid? filter, EntityUid item) => IsBlocked(filter, item, out var couldAllow) && !couldAllow;
+
+ ///
+ /// Gets the split size for a filter.
+ /// If non-zero then the pulled item is split into a multiple of the return value.
+ /// If zero then nothing special is done.
+ ///
+ public int GetSplitSize(EntityUid? filter)
+ {
+ if (filter is not {} uid)
+ return 0;
+
+ var ev = new AutomationFilterSplitEvent();
+ RaiseLocalEvent(uid, ref ev);
+ return ev.Size;
+ }
+
+ public EntityUid? TrySplit(EntityUid? filter, EntityUid item)
+ {
+ // if it's 0 don't need to split, take the item out directly
+ var split = GetSplitSize(filter);
+ if (split == 0)
+ return item;
+
+ // don't need to split if it's already a multiple of the split size
+ var stack = Comp(item);
+ var excess = stack.Count % split;
+ if (excess == 0)
+ return item;
+
+ // have to split it, client will return null here
+ var coords = Transform(item).Coordinates;
+ return _stack.Split((item, stack), stack.Count - excess, coords); // Art-change
+ }
+
+ ///
+ /// Get the filter in a machine's filter slot, or null if it has none.
+ ///
+ public EntityUid? GetSlot(EntityUid uid)
+ {
+ return _slotQuery.CompOrNull(uid)?.Filter;
+ }
+ #endregion
+}
diff --git a/Content.Shared/_Goobstation/Factory/Filters/CombinedFilterComponent.cs b/Content.Shared/_Goobstation/Factory/Filters/CombinedFilterComponent.cs
new file mode 100644
index 00000000000..6082a63cc8f
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/Filters/CombinedFilterComponent.cs
@@ -0,0 +1,41 @@
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.DeviceLinking;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Goobstation.Factory.Filters;
+
+///
+/// Filter that combines 2 other filters using a logical operation.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(AutomationFilterSystem))]
+[AutoGenerateComponentState]
+public sealed partial class CombinedFilterComponent : Component
+{
+ ///
+ /// Name of the first filter slot.
+ ///
+ public const string FilterAName = "combined_filter_a";
+
+ ///
+ /// Name of the second filter slot.
+ ///
+ public const string FilterBName = "combined_filter_b";
+
+ ///
+ /// The slot for the first filter.
+ ///
+ [ViewVariables]
+ public ItemSlot FilterA = default!;
+
+ ///
+ /// The slot for the second filter.
+ ///
+ [ViewVariables]
+ public ItemSlot FilterB = default!;
+
+ ///
+ /// Logic gate operation to check the inputs with.
+ ///
+ [DataField, AutoNetworkedField]
+ public LogicGate Gate = LogicGate.Or;
+}
diff --git a/Content.Shared/_Goobstation/Factory/Filters/FilterSlotComponent.cs b/Content.Shared/_Goobstation/Factory/Filters/FilterSlotComponent.cs
new file mode 100644
index 00000000000..587396a3d53
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/Filters/FilterSlotComponent.cs
@@ -0,0 +1,29 @@
+using Content.Shared.Containers.ItemSlots;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Goobstation.Factory.Filters;
+
+///
+/// Component for machines that have a filter slot.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(AutomationFilterSystem))]
+public sealed partial class FilterSlotComponent : Component
+{
+ ///
+ /// Item slot that stores a filter.
+ ///
+ [DataField]
+ public string FilterSlotId = "filter_slot";
+
+ ///
+ /// The filter slot cached on init.
+ ///
+ [ViewVariables]
+ public ItemSlot FilterSlot = default!;
+
+ ///
+ /// The currently inserted filter.
+ ///
+ [ViewVariables]
+ public EntityUid? Filter => FilterSlot.Item;
+}
diff --git a/Content.Shared/_Goobstation/Factory/Filters/LabelFilterComponent.cs b/Content.Shared/_Goobstation/Factory/Filters/LabelFilterComponent.cs
new file mode 100644
index 00000000000..ddfa629c7e7
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/Filters/LabelFilterComponent.cs
@@ -0,0 +1,38 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Goobstation.Factory.Filters;
+
+///
+/// A filter that requires items to have the exact same label as a set string.
+/// Items without a label will always fail it.
+/// Set labels using a hand labeler.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(AutomationFilterSystem))]
+[AutoGenerateComponentState]
+public sealed partial class LabelFilterComponent : Component
+{
+ ///
+ /// The label to require.
+ ///
+ [DataField, AutoNetworkedField]
+ public string Label = string.Empty;
+
+ ///
+ /// Max length for .
+ ///
+ [DataField]
+ public int MaxLength = 50;
+}
+
+[Serializable, NetSerializable]
+public enum LabelFilterUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed partial class LabelFilterSetLabelMessage(string label) : BoundUserInterfaceMessage
+{
+ public readonly string Label = label;
+}
diff --git a/Content.Shared/_Goobstation/Factory/Filters/NameFilterComponent.cs b/Content.Shared/_Goobstation/Factory/Filters/NameFilterComponent.cs
new file mode 100644
index 00000000000..5cc0360f912
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/Filters/NameFilterComponent.cs
@@ -0,0 +1,61 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Goobstation.Factory.Filters;
+
+///
+/// A filter that requires items to have the exact same name as a set string.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(AutomationFilterSystem))]
+[AutoGenerateComponentState]
+public sealed partial class NameFilterComponent : Component
+{
+ ///
+ /// The string to compare to the item name.
+ ///
+ [DataField, AutoNetworkedField]
+ public string Name = string.Empty;
+
+ ///
+ /// Max length for .
+ ///
+ [DataField]
+ public int MaxLength = 50;
+
+ ///
+ /// The filtering mode to use with .
+ ///
+ [DataField, AutoNetworkedField]
+ public NameFilterMode Mode = NameFilterMode.Contain;
+}
+
+[Serializable, NetSerializable]
+public enum NameFilterMode : byte
+{
+ // Name must contain a string somewhere
+ Contain,
+ // Name must start with a string
+ Start,
+ // Name must end with a string
+ End,
+ // Name must match exactly, even if it's labelled
+ Match
+}
+
+[Serializable, NetSerializable]
+public enum NameFilterUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed partial class NameFilterSetNameMessage(string name) : BoundUserInterfaceMessage
+{
+ public readonly string Name = name;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class NameFilterSetModeMessage(NameFilterMode mode) : BoundUserInterfaceMessage
+{
+ public readonly NameFilterMode Mode = mode;
+}
diff --git a/Content.Shared/_Goobstation/Factory/Filters/PressureFilterComponent.cs b/Content.Shared/_Goobstation/Factory/Filters/PressureFilterComponent.cs
new file mode 100644
index 00000000000..fb714deed07
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/Filters/PressureFilterComponent.cs
@@ -0,0 +1,44 @@
+using Content.Shared.Atmos;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Goobstation.Factory.Filters;
+
+///
+/// Requires that the pressure of an entity's gas mixture is within some range.
+/// Since atmos is server only, client will predict it blocking everything.
+///
+[RegisterComponent, NetworkedComponent]
+[AutoGenerateComponentState]
+public sealed partial class PressureFilterComponent : Component
+{
+ ///
+ /// Minimum pressure to require.
+ ///
+ [DataField, AutoNetworkedField]
+ public float Min;
+
+ ///
+ /// Maximum pressure to require.
+ ///
+ [DataField, AutoNetworkedField]
+ public float Max = Atmospherics.OneAtmosphere * 10f;
+}
+
+[Serializable, NetSerializable]
+public enum PressureFilterUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed partial class PressureFilterSetMinMessage(float min) : BoundUserInterfaceMessage
+{
+ public readonly float Min = min;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class PressureFilterSetMaxMessage(float max) : BoundUserInterfaceMessage
+{
+ public readonly float Max = max;
+}
diff --git a/Content.Shared/_Goobstation/Factory/Filters/StackFilterComponent.cs b/Content.Shared/_Goobstation/Factory/Filters/StackFilterComponent.cs
new file mode 100644
index 00000000000..005816675f5
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/Filters/StackFilterComponent.cs
@@ -0,0 +1,45 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Goobstation.Factory.Filters;
+
+///
+/// A filter that requires items to have a minimum stack size.
+/// Non-stackable items will always be blocked.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(AutomationFilterSystem))]
+[AutoGenerateComponentState]
+public sealed partial class StackFilterComponent : Component
+{
+ ///
+ /// Minimum stack size to require.
+ ///
+ [DataField, AutoNetworkedField]
+ public int Min = 1;
+
+ ///
+ /// Items must be taken out in chunks of this size.
+ /// Combining more than stack filter makes it use the highest set chunk size.
+ /// If 0 then output is not chunked.
+ ///
+ [DataField, AutoNetworkedField]
+ public int Size;
+}
+
+[Serializable, NetSerializable]
+public enum StackFilterUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed partial class StackFilterSetMinMessage(int min) : BoundUserInterfaceMessage
+{
+ public readonly int Min = min;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class StackFilterSetSizeMessage(int size) : BoundUserInterfaceMessage
+{
+ public readonly int Size = size;
+}
diff --git a/Content.Shared/_Goobstation/Factory/InteractorComponent.cs b/Content.Shared/_Goobstation/Factory/InteractorComponent.cs
new file mode 100644
index 00000000000..d0434a63ced
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/InteractorComponent.cs
@@ -0,0 +1,54 @@
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.DeviceLinking;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Goobstation.Factory;
+
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedInteractorSystem))]
+[AutoGenerateComponentState(fieldDeltas: true)]
+public sealed partial class InteractorComponent : Component
+{
+ [DataField]
+ public string ToolContainerId = "interactor_tool";
+
+ ///
+ /// Fixture to look for target items with.
+ ///
+ [DataField]
+ public string TargetFixtureId = "interactor_target";
+
+ ///
+ /// Entities currently colliding with and whether their CollisionWake was enabled.
+ /// When entities start to collide they get pushed to the end.
+ /// When picking up items the last value is taken.
+ /// This is essentially a FILO queue.
+ ///
+ [DataField, AutoNetworkedField]
+ public List<(NetEntity, bool)> TargetEntities = new();
+}
+
+[Serializable, NetSerializable]
+public enum InteractorVisuals : byte
+{
+ State
+}
+
+[Serializable, NetSerializable]
+public enum InteractorLayers : byte
+{
+ Hand,
+ Powered
+}
+
+[Serializable, NetSerializable]
+public enum InteractorState : byte
+{
+ // Inactive with no tool
+ Empty,
+ // Inactive with a tool
+ Inactive,
+ // Active, with or without a tool
+ Active
+}
diff --git a/Content.Shared/_Goobstation/Factory/RoboticArmComponent.cs b/Content.Shared/_Goobstation/Factory/RoboticArmComponent.cs
new file mode 100644
index 00000000000..5093e52cbe4
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/RoboticArmComponent.cs
@@ -0,0 +1,167 @@
+using Content.Shared._Goobstation.Factory.Slots;
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.DeviceLinking;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared._Goobstation.Factory;
+
+[RegisterComponent, NetworkedComponent, Access(typeof(RoboticArmSystem))]
+[AutoGenerateComponentState(true, fieldDeltas: true), AutoGenerateComponentPause]
+public sealed partial class RoboticArmComponent : Component
+{
+ #region Linking
+ ///
+ /// Machine linked to the input port.
+ /// Might not always exist.
+ ///
+ [DataField, AutoNetworkedField]
+ public NetEntity? InputMachine;
+
+ ///
+ /// Sink port on this arm that machines link to.
+ ///
+ [DataField]
+ public ProtoId InputPort = "RoboticArmInput";
+
+ ///
+ /// The source port of the linked input machine.
+ /// This controls which item slot etc gets pulled from.
+ ///
+ [DataField, AutoNetworkedField]
+ public ProtoId? InputMachinePort;
+
+ ///
+ /// The resolved automation output slot of the input machine to take items from.
+ ///
+ [ViewVariables]
+ public AutomationSlot? InputSlot;
+
+ ///
+ /// Machine linked to the output port.
+ /// Might not always exist.
+ ///
+ [DataField, AutoNetworkedField]
+ public NetEntity? OutputMachine;
+
+ ///
+ /// Source port on this arm that machines link from.
+ ///
+ [DataField]
+ public ProtoId OutputPort = "RoboticArmOutput";
+
+ ///
+ /// The sink port of the linked output machine.
+ /// This controls which item slot etc gets inserted into.
+ ///
+ [DataField, AutoNetworkedField]
+ public ProtoId? OutputMachinePort;
+
+ ///
+ /// The resolved automation input slot of the output machine to insert items into.
+ ///
+ [ViewVariables]
+ public AutomationSlot? OutputSlot;
+
+ ///
+ /// Signal port invoked after an item gets moved.
+ ///
+ [DataField]
+ public ProtoId MovedPort = "RoboticArmMoved";
+ #endregion
+
+ #region Item Slot
+ ///
+ /// Item slot that stores the held item.
+ ///
+ [DataField]
+ public string ItemSlotId = "robotic_arm_item";
+
+ ///
+ /// The item slot cached on init.
+ ///
+ [ViewVariables]
+ public ItemSlot ItemSlot = default!;
+
+ ///
+ /// The currently held item.
+ ///
+ [ViewVariables]
+ public EntityUid? HeldItem => ItemSlot.Item;
+
+ ///
+ /// Whether an item is currently held.
+ ///
+ public bool HasItem => ItemSlot.HasItem;
+ #endregion
+
+ #region Input Items
+ ///
+ /// Fixture to look for input items with when no input machine is linked.
+ ///
+ [DataField]
+ public string InputFixtureId = "robotic_arm_input";
+
+ ///
+ /// Items currently colliding with and whether their CollisionWake was enabled.
+ /// When items start to collide they get pushed to the end.
+ /// When picking up items the last value is taken.
+ /// This is essentially a FILO queue.
+ ///
+ [DataField, AutoNetworkedField]
+ public List<(NetEntity, bool)> InputItems = new();
+ #endregion
+
+ #region Arm Moving
+ ///
+ /// How long it takes to move an item.
+ ///
+ [DataField]
+ public TimeSpan MoveDelay = TimeSpan.FromSeconds(0.6);
+
+ ///
+ /// When the arm will next move to the input or output.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ [AutoNetworkedField, AutoPausedField]
+ public TimeSpan? NextMove;
+
+ ///
+ /// Sound played when moving an item.
+ ///
+ [DataField]
+ public SoundSpecifier? MoveSound;
+ #endregion
+
+ #region Power
+
+ ///
+ /// Power used when idle.
+ ///
+ [DataField]
+ public float IdlePowerDraw = 50f;
+
+ ///
+ /// Power used when moving items.
+ ///
+ [DataField]
+ public float MovingPowerDraw = 200f; // DeltaV - was 3000f
+
+ #endregion
+}
+
+[Serializable, NetSerializable]
+public enum RoboticArmVisuals : byte
+{
+ HasItem
+}
+
+[Serializable, NetSerializable]
+public enum RoboticArmLayers : byte
+{
+ Arm,
+ Powered
+}
diff --git a/Content.Shared/_Goobstation/Factory/RoboticArmSystem.cs b/Content.Shared/_Goobstation/Factory/RoboticArmSystem.cs
new file mode 100644
index 00000000000..a1e11b65fd7
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/RoboticArmSystem.cs
@@ -0,0 +1,447 @@
+using Content.Shared._Goobstation.Factory.Filters;
+using Content.Shared._Goobstation.Factory.Slots;
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.DeviceLinking;
+using Content.Shared.DeviceLinking.Events;
+using Content.Shared.Examine;
+using Content.Shared.Item;
+using Content.Shared.Maps;
+using Content.Shared.Physics;
+using Content.Shared.Throwing;
+using Content.Shared.Power.Components;
+using Content.Shared.Power.EntitySystems;
+using Robust.Shared.Containers;
+using Robust.Shared.Map;
+using Robust.Shared.Physics.Events;
+using Robust.Shared.Timing;
+
+namespace Content.Shared._Goobstation.Factory;
+
+public sealed class RoboticArmSystem : EntitySystem
+{
+ [Dependency] private readonly AutomationSystem _automation = default!;
+ [Dependency] private readonly AutomationFilterSystem _filter = default!;
+ [Dependency] private readonly CollisionWakeSystem _wake = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IMapManager _map = default!;
+ [Dependency] private readonly ItemSlotsSystem _slots = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly SharedDeviceLinkSystem _device = default!;
+ [Dependency] private readonly SharedPowerReceiverSystem _power = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+ [Dependency] private readonly TurfSystem _turf = default!;
+
+ private EntityQuery _itemQuery;
+ private EntityQuery _thrownQuery;
+ private TimeSpan _nextUpdate = TimeSpan.Zero;
+ private static readonly TimeSpan _updateDelay = TimeSpan.FromSeconds(0.5);
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _itemQuery = GetEntityQuery();
+ _thrownQuery = GetEntityQuery();
+
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(OnExamined);
+ SubscribeLocalEvent(OnHandleState);
+ // input items
+ SubscribeLocalEvent(OnStartCollide);
+ SubscribeLocalEvent(OnEndCollide);
+ // HasItem visuals
+ SubscribeLocalEvent(OnItemModified);
+ SubscribeLocalEvent(OnItemModified);
+ // linking
+ SubscribeLocalEvent(OnLinkAttempt);
+ SubscribeLocalEvent(OnNewLink);
+ SubscribeLocalEvent(OnPortDisconnected);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var now = _timing.CurTime;
+ if (_nextUpdate < now)
+ return;
+
+ _nextUpdate += _updateDelay;
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (!_power.IsPowered(uid))
+ continue;
+
+ if (comp.NextMove is {} nextMove && now < nextMove)
+ continue;
+
+ var ent = (uid, comp);
+ StopMoving(ent);
+
+ if (comp.HeldItem is {} item)
+ {
+ if (!TryDrop(ent, item))
+ continue;
+
+ StartMoving(ent);
+ _device.InvokePort(uid, comp.MovedPort);
+ }
+ else if (TryPickupAny(ent))
+ {
+ StartMoving(ent);
+ }
+ }
+ }
+
+ private void OnInit(Entity ent, ref ComponentInit args)
+ {
+ _device.EnsureSinkPorts(ent, ent.Comp.InputPort);
+ _device.EnsureSourcePorts(ent, ent.Comp.OutputPort, ent.Comp.MovedPort);
+
+ UpdateSlots(ent);
+
+ UpdateItemSlots(ent);
+ }
+
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ using (args.PushGroup(nameof(RoboticArmComponent)))
+ {
+ args.PushMarkup(_filter.GetSlot(ent) is {} filter
+ ? Loc.GetString("robotic-arm-examine-filter", ("filter", filter))
+ : Loc.GetString("robotic-arm-examine-no-filter"));
+ args.PushMarkup(ent.Comp.HeldItem is {} item
+ ? Loc.GetString("robotic-arm-examine-item", ("item", item))
+ : Loc.GetString("robotic-arm-examine-no-item"));
+ }
+ }
+
+ private void OnHandleState(Entity ent, ref AfterAutoHandleStateEvent args)
+ {
+ // incase client didnt predict linked port changing, update them
+ UpdateSlots(ent);
+ }
+
+ private void OnStartCollide(Entity ent, ref StartCollideEvent args)
+ {
+ // only care about items in the input area
+ if (args.OurFixtureId != ent.Comp.InputFixtureId)
+ return;
+
+ AddInput(ent, args.OtherEntity);
+ }
+
+ private void AddInput(Entity ent, EntityUid item)
+ {
+ // never pick up non-items
+ if (!_itemQuery.HasComp(item))
+ return;
+
+ // thrown items move too fast to be caught...
+ if (_thrownQuery.HasComp(item))
+ return;
+
+ // ignore items filters will never allow
+ // not using IsBlocked since gas tanks can change pressure in a canister and need to be checked
+ if (_filter.IsAlwaysBlocked(_filter.GetSlot(ent), item))
+ return;
+
+ var wake = CompOrNull(item);
+ var wakeEnabled = wake?.Enabled ?? false;
+ // need to only get EndCollide when it leaves the area, not when it sleeps
+ _wake.SetEnabled(item, false, wake);
+ ent.Comp.InputItems.Add((GetNetEntity(item), wakeEnabled));
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.InputItems));
+ }
+
+ private void OnEndCollide(Entity ent, ref EndCollideEvent args)
+ {
+ // only care about items leaving the input area
+ if (args.OurFixtureId != ent.Comp.InputFixtureId)
+ return;
+
+ var item = GetNetEntity(args.OtherEntity);
+ var i = ent.Comp.InputItems.FindIndex(pair => pair.Item1 == item);
+ if (i < 0)
+ return;
+
+ var wake = ent.Comp.InputItems[i].Item2;
+ ent.Comp.InputItems.RemoveAt(i);
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.InputItems));
+ _wake.SetEnabled(args.OtherEntity, wake); // don't break conveyors for skipped items
+ }
+
+ private void OnItemModified(Entity ent, ref T args) where T: ContainerModifiedMessage
+ {
+ if (args.Container.ID != ent.Comp.ItemSlotId)
+ return;
+
+ // need to do this here for flatpacking at least from PVS stuff
+ UpdateItemSlots(ent);
+ _appearance.SetData(ent, RoboticArmVisuals.HasItem, ent.Comp.HasItem);
+ }
+
+ private void OnLinkAttempt(Entity ent, ref LinkAttemptEvent args)
+ {
+ // only prevent linking machines, don't care about control ports
+ var linkingOutput = args.SourcePort == ent.Comp.OutputPort;
+ var linkingInput = args.SinkPort == ent.Comp.InputPort;
+ if (!linkingOutput && !linkingInput)
+ return;
+
+ if (ent.Owner == args.Source && linkingOutput)
+ {
+ // only 1 machine
+ if (GetOutputMachine(ent) != null)
+ {
+ args.Cancel();
+ return;
+ }
+
+ // make sure the port is for an automation slot
+ if (!_automation.HasSlot(args.Sink, args.SinkPort, input: true))
+ {
+ args.Cancel();
+ return;
+ }
+ }
+ else if (ent.Owner == args.Sink && linkingInput)
+ {
+ // only 1 machine
+ if (GetInputMachine(ent) != null)
+ {
+ args.Cancel();
+ return;
+ }
+
+ // make sure the port is for an automation slot
+ if (!_automation.HasSlot(args.Source, args.SourcePort, input: false))
+ {
+ args.Cancel();
+ return;
+ }
+ }
+ }
+
+ private void OnNewLink(Entity ent, ref NewLinkEvent args)
+ {
+ if (args.SinkPort == ent.Comp.InputPort)
+ {
+ ent.Comp.InputMachine = GetNetEntity(args.Source);
+ ent.Comp.InputMachinePort = args.SourcePort;
+ ent.Comp.InputSlot = _automation.GetSlot(args.Source, args.SourcePort, input: false);
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.InputMachine));
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.InputMachinePort));
+ }
+ else if (args.SourcePort == ent.Comp.OutputPort)
+ {
+ ent.Comp.OutputMachine = GetNetEntity(args.Sink);
+ ent.Comp.OutputMachinePort = args.SinkPort;
+ ent.Comp.OutputSlot = _automation.GetSlot(args.Sink, args.SinkPort, input: true);
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.OutputMachine));
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.OutputMachinePort));
+ }
+ }
+
+ private void OnPortDisconnected(Entity ent, ref PortDisconnectedEvent args)
+ {
+ // this event is shit and doesnt have source/sink entity and port just 1 string
+ // so if you made InputPort and OutputPort the same string it would silently break
+ // absolute supercode
+ if (args.Port == ent.Comp.InputPort)
+ {
+ ent.Comp.InputMachine = null;
+ ent.Comp.InputMachinePort = null;
+ ent.Comp.InputSlot = null;
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.InputMachine));
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.InputMachinePort));
+ }
+ else if (args.Port == ent.Comp.OutputPort)
+ {
+ ent.Comp.OutputMachine = null;
+ ent.Comp.OutputMachinePort = null;
+ ent.Comp.OutputSlot = null;
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.OutputMachine));
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.OutputMachinePort));
+ }
+ }
+
+ ///
+ /// If a machine is linked for the arm's output, tries to insert into it.
+ /// If there is no machine linked it just gets dropped.
+ ///
+ public bool TryDrop(Entity ent, EntityUid item)
+ {
+ if (GetOutputMachine(ent) is {} machine && ent.Comp.OutputSlot is {} slot)
+ return TryInsert(ent, item, machine, slot);
+
+ // no dropping items into walls
+ if (IsOutputBlocked(ent))
+ return false;
+
+ // nothing linked, just drop it there
+ _transform.SetCoordinates(item, OutputPosition(ent));
+ return true;
+ }
+
+ public bool TryInsert(Entity ent, EntityUid item, EntityUid machine, AutomationSlot slot)
+ {
+ // prevent linking a machine then moving it far away, it has to be at the output area
+ var coords = OutputPosition(ent);
+ if (!_transform.InRange(Transform(machine).Coordinates, coords, 0.25f))
+ return false;
+
+ return slot.Insert(item);
+ }
+
+ public bool TryPickupAny(Entity ent)
+ {
+ if (GetInputMachine(ent) is {} machine && ent.Comp.InputSlot is {} slot)
+ return TryPickupFrom(ent, machine, slot);
+
+ var count = ent.Comp.InputItems.Count;
+ if (count == 0)
+ return false;
+
+ var output = ent.Comp.OutputSlot;
+ if (output == null && IsOutputBlocked(ent))
+ return false;
+
+ var filter = _filter.GetSlot(ent);
+
+ // check them in reverse since removing near the end is cheaper
+ var found = EntityUid.Invalid;
+ for (var i = count - 1; i >= 0; i--)
+ {
+ var netEnt = ent.Comp.InputItems[i].Item1;
+ if (!TryGetEntity(netEnt, out var item))
+ continue;
+
+ if (_filter.IsBlocked(filter, item.Value))
+ continue;
+
+ // make sure the destination will accept it or it gets stuck
+ if (output?.CanInsert(item.Value) ?? true)
+ {
+ ent.Comp.InputItems.RemoveAt(i);
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.InputItems));
+ found = item.Value;
+ break;
+ }
+ }
+
+ // nothing :(
+ if (!found.Valid)
+ return false;
+
+ // no longer need this
+ _wake.SetEnabled(found, false);
+
+ // insert it into the arm slot
+ return _slots.TryInsert(ent, ent.Comp.ItemSlot, found, user: null);
+ }
+
+ public bool TryPickupFrom(Entity ent, EntityUid machine, AutomationSlot slot)
+ {
+ // prevent linking a machine then moving it far away, it has to be at the input area
+ var coords = InputPosition(ent);
+ if (!_transform.InRange(Transform(machine).Coordinates, coords, 0.25f))
+ return false;
+
+ var filter = _filter.GetSlot(ent);
+ if (slot.GetItem(filter) is not {} item)
+ return false;
+
+ // client can't predict splitting because it spawns entities
+ if (_filter.TrySplit(filter, item) is not {} stack)
+ return false;
+
+ return _slots.TryInsert(ent, ent.Comp.ItemSlot, stack, user: null);
+ }
+
+ private void UpdateSlots(Entity ent)
+ {
+ if (GetInputMachine(ent) is {} input && ent.Comp.InputMachinePort is {} inPort)
+ ent.Comp.InputSlot = _automation.GetSlot(input, inPort, input: false);
+ if (GetOutputMachine(ent) is {} output && ent.Comp.OutputMachinePort is {} outPort)
+ ent.Comp.OutputSlot = _automation.GetSlot(output, outPort, input: true);
+ }
+
+ private void UpdateItemSlots(Entity ent)
+ {
+ if (ent.Comp.ItemSlot != null)
+ return;
+
+ if (!TryComp(ent, out var slots))
+ return;
+
+ if (!_slots.TryGetSlot(ent, ent.Comp.ItemSlotId, out var slot, slots))
+ {
+ Log.Warning($"Missing item slot {ent.Comp.ItemSlotId} on robotic arm {ToPrettyString(ent)}");
+ RemCompDeferred(ent);
+ return;
+ }
+
+ ent.Comp.ItemSlot = slot;
+ }
+
+ private bool IsOutputBlocked(EntityUid uid)
+ {
+ var coords = OutputPosition(uid);
+ return _turf.GetTileRef(coords) is {} turf && // Art-change
+ _turf.IsTileBlocked(turf, CollisionGroup.MachineMask);
+ }
+
+ private void StartMoving(Entity ent)
+ {
+ //SetPowerDraw(ent, ent.Comp.MovingPowerDraw); - ported from Impstation, static power draw to prever seizure inducing power flashes
+ ent.Comp.NextMove = _timing.CurTime + ent.Comp.MoveDelay;
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.NextMove));
+ }
+
+ private void StopMoving(Entity ent)
+ {
+ // SetPowerDraw(ent, ent.Comp.IdlePowerDraw); - ported from Impstation, static power draw to prever seizure inducing power flashes
+ ent.Comp.NextMove = null;
+ DirtyField(ent, ent.Comp, nameof(RoboticArmComponent.NextMove));
+ }
+
+ // private void SetPowerDraw(EntityUid uid, float draw) - ported from Impstation, static power draw to prever seizure inducing power flashes
+ // {
+ // SharedApcPowerReceiverComponent? receiver = null;
+ // if (_power.ResolveApc(uid, ref receiver))
+ // _power.SetLoad(receiver, draw);
+ // }
+
+ public EntityCoordinates OutputPosition(EntityUid uid)
+ {
+ var xform = Transform(uid);
+ var offset = xform.LocalRotation.ToVec();
+ // positive would be where the input fixture is...
+ return xform.Coordinates.Offset(-offset);
+ }
+
+ public EntityCoordinates InputPosition(EntityUid uid)
+ {
+ var xform = Transform(uid);
+ var offset = xform.LocalRotation.ToVec();
+ return xform.Coordinates.Offset(offset);
+ }
+
+ private EntityUid? GetInputMachine(RoboticArmComponent comp)
+ {
+ TryGetEntity(comp.InputMachine, out var machine);
+ return machine;
+ }
+
+ private EntityUid? GetOutputMachine(RoboticArmComponent comp)
+ {
+ TryGetEntity(comp.OutputMachine, out var machine);
+ return machine;
+ }
+}
diff --git a/Content.Shared/_Goobstation/Factory/SharedConstructorSystem.cs b/Content.Shared/_Goobstation/Factory/SharedConstructorSystem.cs
new file mode 100644
index 00000000000..97de6f704d2
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/SharedConstructorSystem.cs
@@ -0,0 +1,59 @@
+using Content.Shared._Goobstation.Construction;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Database;
+using Content.Shared.Examine;
+using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._Goobstation.Factory;
+
+public abstract class SharedConstructorSystem : EntitySystem
+{
+ [Dependency] protected readonly ISharedAdminLogManager _adminLogger = default!;
+ [Dependency] protected readonly IPrototypeManager Proto = default!;
+ [Dependency] protected readonly SharedTransformSystem _transform = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnExamined);
+ SubscribeLocalEvent(OnConstructed);
+ Subs.BuiEvents(ConstructorUiKey.Key, subs =>
+ {
+ subs.Event(OnSetProto);
+ });
+ }
+
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ var msg = ent.Comp.Construction is {} id
+ ? Loc.GetString("constructor-examine", ("name", Proto.Index(id))) // Art-change
+ : Loc.GetString("constructor-examine-unset");
+ args.PushMarkup(msg);
+ }
+
+ private void OnConstructed(Entity ent, ref ConstructedEvent args) =>
+ _transform.SetCoordinates(args.Entity, OutputPosition(ent));
+
+ private void OnSetProto(Entity ent, ref ConstructorSetProtoMessage args)
+ {
+ if (ent.Comp.Construction == args.Id
+ || !Proto.HasIndex(args.Id))
+ return;
+
+ ent.Comp.Construction = args.Id;
+ Dirty(ent);
+ _adminLogger.Add(LogType.Construction, LogImpact.Low, $"{ToPrettyString(args.Actor):user} set {ToPrettyString(ent):target} construction to {args.Id}");
+ }
+
+ public EntityCoordinates OutputPosition(EntityUid uid)
+ {
+ var xform = Transform(uid);
+ var offset = xform.LocalRotation.ToVec();
+ return xform.Coordinates.Offset(offset);
+ }
+}
diff --git a/Content.Shared/_Goobstation/Factory/SharedInteractorSystem.cs b/Content.Shared/_Goobstation/Factory/SharedInteractorSystem.cs
new file mode 100644
index 00000000000..db5f7275ae9
--- /dev/null
+++ b/Content.Shared/_Goobstation/Factory/SharedInteractorSystem.cs
@@ -0,0 +1,184 @@
+using Content.Shared._Goobstation.DoAfter;
+using Content.Shared._Goobstation.Factory.Filters;
+using Content.Shared.DeviceLinking;
+using Content.Shared.DoAfter;
+using Content.Shared.Examine;
+using Content.Shared.Hands.Components;
+using Content.Shared.Interaction;
+using Content.Shared.Throwing;
+using Content.Shared.Hands.EntitySystems; // Art-change
+using Robust.Shared.Containers;
+using Robust.Shared.Physics.Events;
+
+namespace Content.Shared._Goobstation.Factory;
+
+public abstract class SharedInteractorSystem : EntitySystem
+{
+ [Dependency] private readonly AutomationSystem _automation = default!;
+ [Dependency] private readonly AutomationFilterSystem _filter = default!;
+ [Dependency] private readonly CollisionWakeSystem _wake = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly SharedInteractionSystem _interaction = default!;
+ [Dependency] private readonly SharedHandsSystem _hands = default!; // Art-change
+ [Dependency] protected readonly StartableMachineSystem Machine = default!;
+
+ private EntityQuery _doAfterQuery;
+ private EntityQuery _handsQuery;
+ private EntityQuery _thrownQuery;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _doAfterQuery = GetEntityQuery();
+ _handsQuery = GetEntityQuery();
+ _thrownQuery = GetEntityQuery();
+
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(OnExamined);
+ SubscribeLocalEvent(OnDoAfterEnded);
+ // target entities
+ SubscribeLocalEvent(OnStartCollide);
+ SubscribeLocalEvent(OnEndCollide);
+ // hand visuals
+ SubscribeLocalEvent(OnItemModified);
+ SubscribeLocalEvent(OnItemModified);
+ }
+
+ private void OnInit(Entity ent, ref ComponentInit args)
+ {
+ UpdateAppearance(ent);
+ }
+
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ args.PushMarkup(_filter.GetSlot(ent) is {} filter
+ ? Loc.GetString("robotic-arm-examine-filter", ("filter", filter))
+ : Loc.GetString("robotic-arm-examine-no-filter"));
+ }
+
+ private void OnStartCollide(Entity ent, ref StartCollideEvent args)
+ {
+ // only care about entities in the target area
+ if (args.OurFixtureId != ent.Comp.TargetFixtureId)
+ return;
+
+ AddTarget(ent, args.OtherEntity);
+ }
+
+ private void AddTarget(Entity ent, EntityUid target)
+ {
+ if (_thrownQuery.HasComp(target) // thrown items move too fast to be "clicked" on...
+ || _filter.IsBlocked(_filter.GetSlot(ent), target)) // ignore non-filtered entities
+ return;
+
+ var wake = CompOrNull(target);
+ var wakeEnabled = wake?.Enabled ?? false;
+ // need to only get EndCollide when it leaves the area, not when it sleeps
+ _wake.SetEnabled(target, false, wake);
+ ent.Comp.TargetEntities.Add((GetNetEntity(target), wakeEnabled));
+ DirtyField(ent, ent.Comp, nameof(InteractorComponent.TargetEntities));
+ }
+
+ private void OnEndCollide(Entity ent, ref EndCollideEvent args)
+ {
+ // only care about entities leaving the input area
+ if (args.OurFixtureId != ent.Comp.TargetFixtureId)
+ return;
+
+ var target = GetNetEntity(args.OtherEntity);
+ var i = ent.Comp.TargetEntities.FindIndex(pair => pair.Item1 == target);
+ if (i < 0)
+ return;
+
+ var wake = ent.Comp.TargetEntities[i].Item2;
+ ent.Comp.TargetEntities.RemoveAt(i);
+ DirtyField(ent, ent.Comp, nameof(InteractorComponent.TargetEntities));
+ _wake.SetEnabled(args.OtherEntity, wake); // don't break conveyors for skipped entities
+ }
+
+ private void OnItemModified(Entity ent, ref T args) where T: ContainerModifiedMessage
+ {
+ if (args.Container.ID != ent.Comp.ToolContainerId)
+ return;
+
+ UpdateAppearance(ent);
+ }
+
+ private void OnDoAfterEnded(Entity ent, ref DoAfterEndedEvent args)
+ {
+ UpdateToolAppearance(ent);
+ if (args.Target is not { } target)
+ return;
+
+ TryRemoveTarget(ent, target);
+
+ if (args.Cancelled)
+ Machine.Failed(ent.Owner);
+ else
+ Machine.Completed(ent.Owner);
+ }
+
+ protected bool HasDoAfter(EntityUid uid) => _doAfterQuery.HasComp(uid);
+
+ protected bool InteractWith(Entity ent, EntityUid target)
+ {
+ if (_handsQuery.CompOrNull(ent)?.ActiveHandId is not {} hand) // Art-change
+ return _interaction.InteractHand(ent, target);
+
+ var coords = Transform(target).Coordinates;
+ // Art-change start
+ var tool = _hands.GetHeldItem((ent, _handsQuery.CompOrNull(ent)), hand);
+
+ if (tool == null)
+ return _interaction.InteractHand(ent, target);
+
+ return _interaction.InteractUsing(ent, tool.Value, target, coords);
+ // Art-change end
+ }
+
+ protected void TryRemoveTarget(Entity