diff --git a/Content.Client/Nutrition/EntitySystems/OpenableSystem.cs b/Content.Client/Nutrition/EntitySystems/OpenableSystem.cs
deleted file mode 100644
index f8c3f7c447..0000000000
--- a/Content.Client/Nutrition/EntitySystems/OpenableSystem.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-using Content.Shared.Nutrition.EntitySystems;
-
-namespace Content.Client.Nutrition.EntitySystems;
-
-public sealed class OpenableSystem : SharedOpenableSystem
-{
-}
diff --git a/Content.Server/Chemistry/EntitySystems/ReagentDispenserSystem.cs b/Content.Server/Chemistry/EntitySystems/ReagentDispenserSystem.cs
index a8583e6bcb..d6433da56a 100644
--- a/Content.Server/Chemistry/EntitySystems/ReagentDispenserSystem.cs
+++ b/Content.Server/Chemistry/EntitySystems/ReagentDispenserSystem.cs
@@ -1,11 +1,12 @@
using Content.Server.Chemistry.Components;
using Content.Server.Chemistry.Containers.EntitySystems;
-using Content.Server.Nutrition.EntitySystems;
+using Content.Server.Nutrition.Components;
using Content.Shared.Chemistry;
using Content.Shared.Chemistry.Dispenser;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Containers.ItemSlots;
using Content.Shared.FixedPoint;
+using Content.Shared.Nutrition.EntitySystems;
using JetBrains.Annotations;
using Robust.Server.Audio;
using Robust.Server.GameObjects;
diff --git a/Content.Server/Chemistry/EntitySystems/SolutionTransferSystem.cs b/Content.Server/Chemistry/EntitySystems/SolutionTransferSystem.cs
deleted file mode 100644
index 1ed5cec8dd..0000000000
--- a/Content.Server/Chemistry/EntitySystems/SolutionTransferSystem.cs
+++ /dev/null
@@ -1,234 +0,0 @@
-using Content.Server.Administration.Logs;
-using Content.Server.Chemistry.Containers.EntitySystems;
-using Content.Shared.Chemistry;
-using Content.Shared.Chemistry.Components;
-using Content.Shared.Database;
-using Content.Shared.FixedPoint;
-using Content.Shared.Interaction;
-using Content.Shared.Popups;
-using Content.Shared.Verbs;
-using JetBrains.Annotations;
-using Robust.Server.GameObjects;
-using Robust.Shared.Player;
-
-namespace Content.Server.Chemistry.EntitySystems
-{
- [UsedImplicitly]
- public sealed class SolutionTransferSystem : EntitySystem
- {
- [Dependency] private readonly SharedPopupSystem _popupSystem = default!;
- [Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!;
- [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!;
- [Dependency] private readonly IAdminLogManager _adminLogger = default!;
-
- ///
- /// Default transfer amounts for the set-transfer verb.
- ///
- public static readonly List DefaultTransferAmounts = new() { 1, 5, 10, 25, 50, 100, 250, 500, 1000 };
-
- public override void Initialize()
- {
- base.Initialize();
-
- SubscribeLocalEvent>(AddSetTransferVerbs);
- SubscribeLocalEvent(OnAfterInteract);
- SubscribeLocalEvent(OnTransferAmountSetValueMessage);
- }
-
- private void OnTransferAmountSetValueMessage(Entity entity, ref TransferAmountSetValueMessage message)
- {
- var newTransferAmount = FixedPoint2.Clamp(message.Value, entity.Comp.MinimumTransferAmount, entity.Comp.MaximumTransferAmount);
- entity.Comp.TransferAmount = newTransferAmount;
-
- if (message.Session.AttachedEntity is { Valid: true } user)
- _popupSystem.PopupEntity(Loc.GetString("comp-solution-transfer-set-amount", ("amount", newTransferAmount)), entity.Owner, user);
- }
-
- private void AddSetTransferVerbs(Entity entity, ref GetVerbsEvent args)
- {
- var (uid, component) = entity;
-
- if (!args.CanAccess || !args.CanInteract || !component.CanChangeTransferAmount || args.Hands == null)
- return;
-
- if (!EntityManager.TryGetComponent(args.User, out ActorComponent? actor))
- return;
-
- // Custom transfer verb
- AlternativeVerb custom = new();
- custom.Text = Loc.GetString("comp-solution-transfer-verb-custom-amount");
- custom.Category = VerbCategory.SetTransferAmount;
- custom.Act = () => _userInterfaceSystem.TryOpen(uid, TransferAmountUiKey.Key, actor.PlayerSession);
- custom.Priority = 1;
- args.Verbs.Add(custom);
-
- // Add specific transfer verbs according to the container's size
- var priority = 0;
- var user = args.User;
- foreach (var amount in DefaultTransferAmounts)
- {
- if (amount < component.MinimumTransferAmount.Int() || amount > component.MaximumTransferAmount.Int())
- continue;
-
- AlternativeVerb verb = new();
- verb.Text = Loc.GetString("comp-solution-transfer-verb-amount", ("amount", amount));
- verb.Category = VerbCategory.SetTransferAmount;
- verb.Act = () =>
- {
- component.TransferAmount = FixedPoint2.New(amount);
- _popupSystem.PopupEntity(Loc.GetString("comp-solution-transfer-set-amount", ("amount", amount)), uid, user);
- };
-
- // we want to sort by size, not alphabetically by the verb text.
- verb.Priority = priority;
- priority--;
-
- args.Verbs.Add(verb);
- }
- }
-
- private void OnAfterInteract(Entity entity, ref AfterInteractEvent args)
- {
- if (!args.CanReach || args.Target == null)
- return;
-
- var target = args.Target!.Value;
- var (uid, component) = entity;
-
- //Special case for reagent tanks, because normally clicking another container will give solution, not take it.
- if (component.CanReceive && !EntityManager.HasComponent(target) // target must not be refillable (e.g. Reagent Tanks)
- && _solutionContainerSystem.TryGetDrainableSolution(target, out var targetSoln, out _) // target must be drainable
- && EntityManager.TryGetComponent(uid, out RefillableSolutionComponent? refillComp)
- && _solutionContainerSystem.TryGetRefillableSolution((uid, refillComp, null), out var ownerSoln, out var ownerRefill))
-
- {
-
- var transferAmount = component.TransferAmount; // This is the player-configurable transfer amount of "uid," not the target reagent tank.
-
- if (EntityManager.TryGetComponent(uid, out RefillableSolutionComponent? refill) && refill.MaxRefill != null) // uid is the entity receiving solution from target.
- {
- transferAmount = FixedPoint2.Min(transferAmount, (FixedPoint2) refill.MaxRefill); // if the receiver has a smaller transfer limit, use that instead
- }
-
- var transferred = Transfer(args.User, target, targetSoln.Value, uid, ownerSoln.Value, transferAmount);
- if (transferred > 0)
- {
- var toTheBrim = ownerRefill.AvailableVolume == 0;
- var msg = toTheBrim
- ? "comp-solution-transfer-fill-fully"
- : "comp-solution-transfer-fill-normal";
-
- _popupSystem.PopupEntity(Loc.GetString(msg, ("owner", args.Target), ("amount", transferred), ("target", uid)), uid, args.User);
-
- args.Handled = true;
- return;
- }
- }
-
- // if target is refillable, and owner is drainable
- if (component.CanSend && _solutionContainerSystem.TryGetRefillableSolution(target, out targetSoln, out var targetRefill)
- && _solutionContainerSystem.TryGetDrainableSolution(uid, out ownerSoln, out var ownerDrain))
- {
- var transferAmount = component.TransferAmount;
-
- if (EntityManager.TryGetComponent(target, out RefillableSolutionComponent? refill) && refill.MaxRefill != null)
- {
- transferAmount = FixedPoint2.Min(transferAmount, (FixedPoint2) refill.MaxRefill);
- }
-
- var transferred = Transfer(args.User, uid, ownerSoln.Value, target, targetSoln.Value, transferAmount);
-
- if (transferred > 0)
- {
- var message = Loc.GetString("comp-solution-transfer-transfer-solution", ("amount", transferred), ("target", target));
- _popupSystem.PopupEntity(message, uid, args.User);
-
- args.Handled = true;
- }
- }
- }
-
- ///
- /// Transfer from a solution to another.
- ///
- /// The actual amount transferred.
- public FixedPoint2 Transfer(EntityUid user,
- EntityUid sourceEntity,
- Entity source,
- EntityUid targetEntity,
- Entity target,
- FixedPoint2 amount)
- {
- var transferAttempt = new SolutionTransferAttemptEvent(sourceEntity, targetEntity);
-
- // Check if the source is cancelling the transfer
- RaiseLocalEvent(sourceEntity, transferAttempt, broadcast: true);
- if (transferAttempt.Cancelled)
- {
- _popupSystem.PopupEntity(transferAttempt.CancelReason!, sourceEntity, user);
- return FixedPoint2.Zero;
- }
-
- var sourceSolution = source.Comp.Solution;
- if (sourceSolution.Volume == 0)
- {
- _popupSystem.PopupEntity(Loc.GetString("comp-solution-transfer-is-empty", ("target", sourceEntity)), sourceEntity, user);
- return FixedPoint2.Zero;
- }
-
- // Check if the target is cancelling the transfer
- RaiseLocalEvent(targetEntity, transferAttempt, broadcast: true);
- if (transferAttempt.Cancelled)
- {
- _popupSystem.PopupEntity(transferAttempt.CancelReason!, sourceEntity, user);
- return FixedPoint2.Zero;
- }
-
- var targetSolution = target.Comp.Solution;
- if (targetSolution.AvailableVolume == 0)
- {
- _popupSystem.PopupEntity(Loc.GetString("comp-solution-transfer-is-full", ("target", targetEntity)), targetEntity, user);
- return FixedPoint2.Zero;
- }
-
- var actualAmount = FixedPoint2.Min(amount, FixedPoint2.Min(sourceSolution.Volume, targetSolution.AvailableVolume));
-
- var solution = _solutionContainerSystem.Drain(sourceEntity, source, actualAmount);
- _solutionContainerSystem.Refill(targetEntity, target, solution);
-
- _adminLogger.Add(LogType.Action, LogImpact.Medium,
- $"{EntityManager.ToPrettyString(user):player} transferred {string.Join(", ", solution.Contents)} to {EntityManager.ToPrettyString(targetEntity):entity}, which now contains {SolutionContainerSystem.ToPrettyString(targetSolution)}");
-
- return actualAmount;
- }
- }
-
- ///
- /// Raised when attempting to transfer from one solution to another.
- ///
- public sealed class SolutionTransferAttemptEvent : CancellableEntityEventArgs
- {
- public SolutionTransferAttemptEvent(EntityUid from, EntityUid to)
- {
- From = from;
- To = to;
- }
-
- public EntityUid From { get; }
- public EntityUid To { get; }
-
- ///
- /// Why the transfer has been cancelled.
- ///
- public string? CancelReason { get; private set; }
-
- ///
- /// Cancels the transfer.
- ///
- public void Cancel(string reason)
- {
- base.Cancel();
- CancelReason = reason;
- }
- }
-}
diff --git a/Content.Server/Destructible/Thresholds/Behaviors/OpenBehavior.cs b/Content.Server/Destructible/Thresholds/Behaviors/OpenBehavior.cs
index f01e4f7048..7ab1fe11b0 100644
--- a/Content.Server/Destructible/Thresholds/Behaviors/OpenBehavior.cs
+++ b/Content.Server/Destructible/Thresholds/Behaviors/OpenBehavior.cs
@@ -1,4 +1,4 @@
-using Content.Server.Nutrition.EntitySystems;
+using Content.Shared.Nutrition.EntitySystems;
namespace Content.Server.Destructible.Thresholds.Behaviors;
diff --git a/Content.Server/Extinguisher/FireExtinguisherSystem.cs b/Content.Server/Extinguisher/FireExtinguisherSystem.cs
index dfecd72398..b33a1af157 100644
--- a/Content.Server/Extinguisher/FireExtinguisherSystem.cs
+++ b/Content.Server/Extinguisher/FireExtinguisherSystem.cs
@@ -73,6 +73,7 @@ public sealed class FireExtinguisherSystem : EntitySystem
args.Handled = true;
+ // TODO: why is this copy paste shit here just have fire extinguisher cancel transfer when safety is on
var transfer = containerSolution.AvailableVolume;
if (TryComp(entity.Owner, out var solTrans))
{
diff --git a/Content.Server/Fluids/EntitySystems/PuddleSystem.Spillable.cs b/Content.Server/Fluids/EntitySystems/PuddleSystem.Spillable.cs
index a365b8d0a4..bd7c55e85e 100644
--- a/Content.Server/Fluids/EntitySystems/PuddleSystem.Spillable.cs
+++ b/Content.Server/Fluids/EntitySystems/PuddleSystem.Spillable.cs
@@ -1,5 +1,5 @@
using Content.Server.Chemistry.Containers.EntitySystems;
-using Content.Server.Nutrition.EntitySystems;
+using Content.Server.Fluids.Components;
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Chemistry.Reaction;
@@ -11,6 +11,7 @@ using Content.Shared.FixedPoint;
using Content.Shared.Fluids.Components;
using Content.Shared.IdentityManagement;
using Content.Shared.Inventory.Events;
+using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Popups;
using Content.Shared.Spillable;
using Content.Shared.Throwing;
diff --git a/Content.Server/Glue/GlueSystem.cs b/Content.Server/Glue/GlueSystem.cs
index 44ff4e5459..ff53ef91ca 100644
--- a/Content.Server/Glue/GlueSystem.cs
+++ b/Content.Server/Glue/GlueSystem.cs
@@ -1,12 +1,12 @@
using Content.Server.Administration.Logs;
using Content.Server.Chemistry.Containers.EntitySystems;
-using Content.Server.Nutrition.EntitySystems;
using Content.Shared.Database;
using Content.Shared.Glue;
using Content.Shared.Hands;
using Content.Shared.Interaction;
using Content.Shared.Interaction.Components;
using Content.Shared.Item;
+using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Audio.Systems;
diff --git a/Content.Server/Lube/LubeSystem.cs b/Content.Server/Lube/LubeSystem.cs
index 5285cb389c..06d6456a57 100644
--- a/Content.Server/Lube/LubeSystem.cs
+++ b/Content.Server/Lube/LubeSystem.cs
@@ -1,12 +1,12 @@
using Content.Server.Administration.Logs;
using Content.Server.Chemistry.Containers.EntitySystems;
-using Content.Server.Nutrition.EntitySystems;
using Content.Shared.Database;
using Content.Shared.Glue;
using Content.Shared.IdentityManagement;
using Content.Shared.Interaction;
using Content.Shared.Item;
using Content.Shared.Lube;
+using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Audio;
diff --git a/Content.Server/Materials/MaterialReclaimerSystem.cs b/Content.Server/Materials/MaterialReclaimerSystem.cs
index ae4444e059..0d6d27777a 100644
--- a/Content.Server/Materials/MaterialReclaimerSystem.cs
+++ b/Content.Server/Materials/MaterialReclaimerSystem.cs
@@ -1,8 +1,6 @@
using Content.Server.Chemistry.Containers.EntitySystems;
-using Content.Server.Chemistry.EntitySystems;
using Content.Server.Fluids.EntitySystems;
using Content.Server.GameTicking;
-using Content.Server.Nutrition.EntitySystems;
using Content.Server.Popups;
using Content.Server.Power.Components;
using Content.Server.Stack;
@@ -10,11 +8,13 @@ using Content.Server.Wires;
using Content.Shared.Body.Systems;
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.Components.SolutionManager;
+using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.IdentityManagement;
using Content.Shared.Interaction;
using Content.Shared.Interaction.Events;
using Content.Shared.Materials;
using Content.Shared.Mind;
+using Content.Shared.Nutrition.EntitySystems;
using Robust.Server.GameObjects;
using Robust.Shared.Player;
using Robust.Shared.Utility;
diff --git a/Content.Server/NPC/Systems/NPCUtilitySystem.cs b/Content.Server/NPC/Systems/NPCUtilitySystem.cs
index 6793161105..e8fb54022e 100644
--- a/Content.Server/NPC/Systems/NPCUtilitySystem.cs
+++ b/Content.Server/NPC/Systems/NPCUtilitySystem.cs
@@ -14,6 +14,7 @@ using Content.Shared.Inventory;
using Content.Shared.Mobs.Systems;
using Content.Shared.NPC.Systems;
using Content.Shared.Nutrition.Components;
+using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Tools.Systems;
using Content.Shared.Weapons.Melee;
using Content.Shared.Weapons.Ranged.Components;
diff --git a/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs b/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs
index 036c855dbb..74637d4813 100644
--- a/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs
+++ b/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs
@@ -24,6 +24,7 @@ using Content.Shared.Interaction.Events;
using Content.Shared.Mobs.Systems;
using Content.Shared.Nutrition;
using Content.Shared.Nutrition.Components;
+using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Throwing;
using Content.Shared.Verbs;
using Robust.Shared.Audio;
diff --git a/Content.Server/Nutrition/EntitySystems/FoodSystem.cs b/Content.Server/Nutrition/EntitySystems/FoodSystem.cs
index d87b0bd0b0..49d7374041 100644
--- a/Content.Server/Nutrition/EntitySystems/FoodSystem.cs
+++ b/Content.Server/Nutrition/EntitySystems/FoodSystem.cs
@@ -23,6 +23,7 @@ using Content.Shared.Interaction.Events;
using Content.Shared.Inventory;
using Content.Shared.Mobs.Systems;
using Content.Shared.Nutrition;
+using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Stacks;
using Content.Shared.Storage;
using Content.Shared.Verbs;
diff --git a/Content.Server/Nutrition/EntitySystems/OpenableSystem.cs b/Content.Server/Nutrition/EntitySystems/OpenableSystem.cs
deleted file mode 100644
index 8037b61572..0000000000
--- a/Content.Server/Nutrition/EntitySystems/OpenableSystem.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using Content.Server.Chemistry.EntitySystems;
-using Content.Shared.Nutrition.EntitySystems;
-using Content.Shared.Nutrition.Components;
-
-namespace Content.Server.Nutrition.EntitySystems;
-
-///
-/// Provides API for openable food and drinks, handles opening on use and preventing transfer when closed.
-///
-public sealed class OpenableSystem : SharedOpenableSystem
-{
- public override void Initialize()
- {
- base.Initialize();
-
- SubscribeLocalEvent(OnTransferAttempt);
- }
-
- private void OnTransferAttempt(EntityUid uid, OpenableComponent comp, SolutionTransferAttemptEvent args)
- {
- if (!comp.Opened)
- {
- // message says its just for drinks, shouldn't matter since you typically dont have a food that is openable and can be poured out
- args.Cancel(Loc.GetString("drink-component-try-use-drink-not-open", ("owner", uid)));
- }
- }
-}
diff --git a/Content.Shared/Chemistry/Components/ScoopableSolutionComponent.cs b/Content.Shared/Chemistry/Components/ScoopableSolutionComponent.cs
new file mode 100644
index 0000000000..6c3f934b7a
--- /dev/null
+++ b/Content.Shared/Chemistry/Components/ScoopableSolutionComponent.cs
@@ -0,0 +1,31 @@
+using Content.Shared.Chemistry.EntitySystems;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Chemistry.Components;
+
+///
+/// Basically reverse spiking, instead of using the solution-entity on a beaker, you use the beaker on the solution-entity.
+/// If there is not enough volume it will stay in the solution-entity rather than spill onto the floor.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(ScoopableSolutionSystem))]
+public sealed partial class ScoopableSolutionComponent : Component
+{
+ ///
+ /// Solution name that can be scooped from.
+ ///
+ [DataField]
+ public string Solution = "default";
+
+ ///
+ /// If true, when the whole solution is scooped up the entity will be deleted.
+ ///
+ [DataField]
+ public bool Delete = true;
+
+ ///
+ /// Popup to show the user when scooping.
+ /// Passed entities "scooped" and "beaker".
+ ///
+ [DataField]
+ public LocId Popup = "scoopable-component-popup";
+}
diff --git a/Content.Shared/Chemistry/EntitySystems/ScoopableSolutionSystem.cs b/Content.Shared/Chemistry/EntitySystems/ScoopableSolutionSystem.cs
new file mode 100644
index 0000000000..84f1e45616
--- /dev/null
+++ b/Content.Shared/Chemistry/EntitySystems/ScoopableSolutionSystem.cs
@@ -0,0 +1,53 @@
+using Content.Shared.Chemistry.Components;
+using Content.Shared.Interaction;
+using Content.Shared.Popups;
+using Robust.Shared.Network;
+
+namespace Content.Shared.Chemistry.EntitySystems;
+
+///
+/// Handles solution transfer when a beaker is used on a scoopable entity.
+///
+public sealed class ScoopableSolutionSystem : EntitySystem
+{
+ [Dependency] private readonly INetManager _netManager = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedSolutionContainerSystem _solution = default!;
+ [Dependency] private readonly SolutionTransferSystem _solutionTransfer = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnInteractUsing);
+ }
+
+ private void OnInteractUsing(Entity ent, ref InteractUsingEvent args)
+ {
+ TryScoop(ent, args.Used, args.User);
+ }
+
+ public bool TryScoop(Entity ent, EntityUid beaker, EntityUid user)
+ {
+ if (!_solution.TryGetSolution(ent.Owner, ent.Comp.Solution, out var src, out var srcSolution) ||
+ !_solution.TryGetRefillableSolution(beaker, out var target, out _))
+ return false;
+
+ var scooped = _solutionTransfer.Transfer(user, ent, src.Value, beaker, target.Value, srcSolution.Volume);
+ if (scooped == 0)
+ return false;
+
+ _popup.PopupClient(Loc.GetString(ent.Comp.Popup, ("scooped", ent.Owner), ("beaker", beaker)), user, user);
+
+ if (srcSolution.Volume == 0 && ent.Comp.Delete)
+ {
+ // deletion isnt predicted so do this to prevent spam clicking to see "the ash is empty!"
+ RemCompDeferred(ent);
+
+ if (!_netManager.IsClient)
+ QueueDel(ent);
+ }
+
+ return true;
+ }
+}
diff --git a/Content.Shared/Chemistry/EntitySystems/SolutionTransferSystem.cs b/Content.Shared/Chemistry/EntitySystems/SolutionTransferSystem.cs
new file mode 100644
index 0000000000..34a64d0edb
--- /dev/null
+++ b/Content.Shared/Chemistry/EntitySystems/SolutionTransferSystem.cs
@@ -0,0 +1,223 @@
+using Content.Shared.Administration.Logs;
+using Content.Shared.Chemistry;
+using Content.Shared.Chemistry.Components;
+using Content.Shared.Database;
+using Content.Shared.FixedPoint;
+using Content.Shared.Interaction;
+using Content.Shared.Popups;
+using Content.Shared.Verbs;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+
+namespace Content.Shared.Chemistry.EntitySystems;
+
+///
+/// Allows an entity to transfer solutions with a customizable amount per click.
+/// Also provides API for other systems.
+///
+public sealed class SolutionTransferSystem : EntitySystem
+{
+ [Dependency] private readonly INetManager _net = default!;
+ [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedSolutionContainerSystem _solution = default!;
+ [Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
+
+ ///
+ /// Default transfer amounts for the set-transfer verb.
+ ///
+ public static readonly FixedPoint2[] DefaultTransferAmounts = new FixedPoint2[] { 1, 5, 10, 25, 50, 100, 250, 500, 1000 };
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent>(AddSetTransferVerbs);
+ SubscribeLocalEvent(OnAfterInteract);
+ SubscribeLocalEvent(OnTransferAmountSetValueMessage);
+ }
+
+ private void OnTransferAmountSetValueMessage(Entity ent, ref TransferAmountSetValueMessage message)
+ {
+ var newTransferAmount = FixedPoint2.Clamp(message.Value, ent.Comp.MinimumTransferAmount, ent.Comp.MaximumTransferAmount);
+ ent.Comp.TransferAmount = newTransferAmount;
+
+ if (message.Session.AttachedEntity is { Valid: true } user)
+ _popup.PopupClient(Loc.GetString("comp-solution-transfer-set-amount", ("amount", newTransferAmount)), ent, user);
+ }
+
+ private void AddSetTransferVerbs(Entity ent, ref GetVerbsEvent args)
+ {
+ var (uid, comp) = ent;
+
+ if (!args.CanAccess || !args.CanInteract || !comp.CanChangeTransferAmount || args.Hands == null)
+ return;
+
+ if (!TryComp(args.User, out var actor))
+ return;
+
+ // Custom transfer verb
+ args.Verbs.Add(new AlternativeVerb()
+ {
+ Text = Loc.GetString("comp-solution-transfer-verb-custom-amount"),
+ Category = VerbCategory.SetTransferAmount,
+ // TODO: remove server check when bui prediction is a thing
+ Act = () =>
+ {
+ if (_net.IsServer)
+ _ui.TryOpen(uid, TransferAmountUiKey.Key, actor.PlayerSession);
+ },
+ Priority = 1
+ });
+
+ // Add specific transfer verbs according to the container's size
+ var priority = 0;
+ var user = args.User;
+ foreach (var amount in DefaultTransferAmounts)
+ {
+ AlternativeVerb verb = new();
+ verb.Text = Loc.GetString("comp-solution-transfer-verb-amount", ("amount", amount));
+ verb.Category = VerbCategory.SetTransferAmount;
+ verb.Act = () =>
+ {
+ comp.TransferAmount = amount;
+ _popup.PopupClient(Loc.GetString("comp-solution-transfer-set-amount", ("amount", amount)), uid, user);
+ };
+
+ // we want to sort by size, not alphabetically by the verb text.
+ verb.Priority = priority;
+ priority--;
+
+ args.Verbs.Add(verb);
+ }
+ }
+
+ private void OnAfterInteract(Entity ent, ref AfterInteractEvent args)
+ {
+ if (!args.CanReach || args.Target is not {} target)
+ return;
+
+ var (uid, comp) = ent;
+
+ //Special case for reagent tanks, because normally clicking another container will give solution, not take it.
+ if (comp.CanReceive
+ && !HasComp(target) // target must not be refillable (e.g. Reagent Tanks)
+ && _solution.TryGetDrainableSolution(target, out var targetSoln, out _) // target must be drainable
+ && TryComp(uid, out var refill)
+ && _solution.TryGetRefillableSolution((uid, refill, null), out var ownerSoln, out var ownerRefill))
+ {
+ var transferAmount = comp.TransferAmount; // This is the player-configurable transfer amount of "uid," not the target reagent tank.
+
+ // if the receiver has a smaller transfer limit, use that instead
+ if (refill?.MaxRefill is {} maxRefill)
+ transferAmount = FixedPoint2.Min(transferAmount, maxRefill);
+
+ var transferred = Transfer(args.User, target, targetSoln.Value, uid, ownerSoln.Value, transferAmount);
+ if (transferred > 0)
+ {
+ var toTheBrim = ownerRefill.AvailableVolume == 0;
+ var msg = toTheBrim
+ ? "comp-solution-transfer-fill-fully"
+ : "comp-solution-transfer-fill-normal";
+
+ _popup.PopupClient(Loc.GetString(msg, ("owner", args.Target), ("amount", transferred), ("target", uid)), uid, args.User);
+
+ args.Handled = true;
+ return;
+ }
+ }
+
+ // if target is refillable, and owner is drainable
+ if (comp.CanSend
+ && TryComp(target, out var targetRefill)
+ && _solution.TryGetRefillableSolution((target, targetRefill, null), out targetSoln, out _)
+ && _solution.TryGetDrainableSolution(uid, out ownerSoln, out _))
+ {
+ var transferAmount = comp.TransferAmount;
+
+ if (targetRefill?.MaxRefill is {} maxRefill)
+ transferAmount = FixedPoint2.Min(transferAmount, maxRefill);
+
+ var transferred = Transfer(args.User, uid, ownerSoln.Value, target, targetSoln.Value, transferAmount);
+
+ if (transferred > 0)
+ {
+ var message = Loc.GetString("comp-solution-transfer-transfer-solution", ("amount", transferred), ("target", target));
+ _popup.PopupClient(message, uid, args.User);
+
+ args.Handled = true;
+ }
+ }
+ }
+
+ ///
+ /// Transfer from a solution to another, allowing either entity to cancel it and show a popup.
+ ///
+ /// The actual amount transferred.
+ public FixedPoint2 Transfer(EntityUid user,
+ EntityUid sourceEntity,
+ Entity source,
+ EntityUid targetEntity,
+ Entity target,
+ FixedPoint2 amount)
+ {
+ var transferAttempt = new SolutionTransferAttemptEvent(sourceEntity, targetEntity);
+
+ // Check if the source is cancelling the transfer
+ RaiseLocalEvent(sourceEntity, ref transferAttempt);
+ if (transferAttempt.CancelReason is {} reason)
+ {
+ _popup.PopupClient(reason, sourceEntity, user);
+ return FixedPoint2.Zero;
+ }
+
+ var sourceSolution = source.Comp.Solution;
+ if (sourceSolution.Volume == 0)
+ {
+ _popup.PopupClient(Loc.GetString("comp-solution-transfer-is-empty", ("target", sourceEntity)), sourceEntity, user);
+ return FixedPoint2.Zero;
+ }
+
+ // Check if the target is cancelling the transfer
+ RaiseLocalEvent(targetEntity, ref transferAttempt);
+ if (transferAttempt.CancelReason is {} targetReason)
+ {
+ _popup.PopupClient(targetReason, targetEntity, user);
+ return FixedPoint2.Zero;
+ }
+
+ var targetSolution = target.Comp.Solution;
+ if (targetSolution.AvailableVolume == 0)
+ {
+ _popup.PopupClient(Loc.GetString("comp-solution-transfer-is-full", ("target", targetEntity)), targetEntity, user);
+ return FixedPoint2.Zero;
+ }
+
+ var actualAmount = FixedPoint2.Min(amount, FixedPoint2.Min(sourceSolution.Volume, targetSolution.AvailableVolume));
+
+ var solution = _solution.SplitSolution(source, actualAmount);
+ _solution.Refill(targetEntity, target, solution);
+
+ _adminLogger.Add(LogType.Action, LogImpact.Medium,
+ $"{ToPrettyString(user):player} transferred {SharedSolutionContainerSystem.ToPrettyString(solution)} to {ToPrettyString(targetEntity):target}, which now contains {SharedSolutionContainerSystem.ToPrettyString(targetSolution)}");
+
+ return actualAmount;
+ }
+}
+
+///
+/// Raised when attempting to transfer from one solution to another.
+/// Raised on both the source and target entities so either can cancel the transfer.
+/// To not mispredict this should always be cancelled in shared code and not server or client.
+///
+[ByRefEvent]
+public record struct SolutionTransferAttemptEvent(EntityUid From, EntityUid To, string? CancelReason = null)
+{
+ ///
+ /// Cancels the transfer.
+ ///
+ public void Cancel(string reason)
+ {
+ CancelReason = reason;
+ }
+}
diff --git a/Content.Shared/Fluids/SharedPuddleSystem.Spillable.cs b/Content.Shared/Fluids/SharedPuddleSystem.Spillable.cs
index 92ea962140..f88f13e8b0 100644
--- a/Content.Shared/Fluids/SharedPuddleSystem.Spillable.cs
+++ b/Content.Shared/Fluids/SharedPuddleSystem.Spillable.cs
@@ -13,7 +13,7 @@ namespace Content.Shared.Fluids;
public abstract partial class SharedPuddleSystem
{
- [Dependency] protected readonly SharedOpenableSystem Openable = default!;
+ [Dependency] protected readonly OpenableSystem Openable = default!;
protected virtual void InitializeSpillable()
{
diff --git a/Content.Shared/Nutrition/Components/OpenableComponent.cs b/Content.Shared/Nutrition/Components/OpenableComponent.cs
index 3a230fc765..0381888e28 100644
--- a/Content.Shared/Nutrition/Components/OpenableComponent.cs
+++ b/Content.Shared/Nutrition/Components/OpenableComponent.cs
@@ -9,7 +9,7 @@ namespace Content.Shared.Nutrition.Components;
/// Starts closed, open it with Z or E.
///
[NetworkedComponent, AutoGenerateComponentState]
-[RegisterComponent, Access(typeof(SharedOpenableSystem))]
+[RegisterComponent, Access(typeof(OpenableSystem))]
public sealed partial class OpenableComponent : Component
{
///
diff --git a/Content.Shared/Nutrition/EntitySystems/SharedOpenableSystem.cs b/Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs
similarity index 91%
rename from Content.Shared/Nutrition/EntitySystems/SharedOpenableSystem.cs
rename to Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs
index f3b1127578..0ad0877d22 100644
--- a/Content.Shared/Nutrition/EntitySystems/SharedOpenableSystem.cs
+++ b/Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs
@@ -1,3 +1,4 @@
+using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Examine;
using Content.Shared.Interaction;
using Content.Shared.Interaction.Events;
@@ -13,7 +14,7 @@ namespace Content.Shared.Nutrition.EntitySystems;
///
/// Provides API for openable food and drinks, handles opening on use and preventing transfer when closed.
///
-public abstract partial class SharedOpenableSystem : EntitySystem
+public sealed partial class OpenableSystem : EntitySystem
{
[Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
[Dependency] protected readonly SharedAudioSystem Audio = default!;
@@ -29,6 +30,7 @@ public abstract partial class SharedOpenableSystem : EntitySystem
SubscribeLocalEvent(HandleIfClosed);
SubscribeLocalEvent(HandleIfClosed);
SubscribeLocalEvent>(AddOpenCloseVerbs);
+ SubscribeLocalEvent(OnTransferAttempt);
}
private void OnInit(EntityUid uid, OpenableComponent comp, ComponentInit args)
@@ -89,6 +91,15 @@ public abstract partial class SharedOpenableSystem : EntitySystem
args.Verbs.Add(verb);
}
+ private void OnTransferAttempt(Entity ent, ref SolutionTransferAttemptEvent args)
+ {
+ if (!ent.Comp.Opened)
+ {
+ // message says its just for drinks, shouldn't matter since you typically dont have a food that is openable and can be poured out
+ args.Cancel(Loc.GetString("drink-component-try-use-drink-not-open", ("owner", ent.Owner)));
+ }
+ }
+
///
/// Returns true if the entity either does not have OpenableComponent or it is opened.
/// Drinks that don't have OpenableComponent are automatically open, so it returns true.
diff --git a/Content.Shared/Nutrition/EntitySystems/SealableSystem.cs b/Content.Shared/Nutrition/EntitySystems/SealableSystem.cs
index b0873f23a1..414b8d182b 100644
--- a/Content.Shared/Nutrition/EntitySystems/SealableSystem.cs
+++ b/Content.Shared/Nutrition/EntitySystems/SealableSystem.cs
@@ -11,7 +11,7 @@ public sealed partial class SealableSystem : EntitySystem
{
base.Initialize();
- SubscribeLocalEvent(OnExamined, after: new[] { typeof(SharedOpenableSystem) });
+ SubscribeLocalEvent(OnExamined, after: new[] { typeof(OpenableSystem) });
SubscribeLocalEvent(OnOpened);
}
diff --git a/Resources/Locale/en-US/chemistry/components/scoopable-component.ftl b/Resources/Locale/en-US/chemistry/components/scoopable-component.ftl
new file mode 100644
index 0000000000..c2593cc61e
--- /dev/null
+++ b/Resources/Locale/en-US/chemistry/components/scoopable-component.ftl
@@ -0,0 +1 @@
+scoopable-component-popup = You scoop up {$scooped} into {THE($beaker)}.
diff --git a/Resources/Prototypes/Entities/Effects/chemistry_effects.yml b/Resources/Prototypes/Entities/Effects/chemistry_effects.yml
index 739464e961..5b7ee46946 100644
--- a/Resources/Prototypes/Entities/Effects/chemistry_effects.yml
+++ b/Resources/Prototypes/Entities/Effects/chemistry_effects.yml
@@ -9,6 +9,7 @@
- type: SmokeVisuals
- type: Transform
anchored: true
+ - type: Clickable
- type: Physics
- type: Fixtures
fixtures:
@@ -76,6 +77,8 @@
animationState: foam-dissolve
- type: Slippery
- type: StepTrigger
+ - type: ScoopableSolution
+ solution: solutionArea
- type: entity
id: MetalFoam
diff --git a/Resources/Prototypes/Entities/Objects/Specific/Medical/morgue.yml b/Resources/Prototypes/Entities/Objects/Specific/Medical/morgue.yml
index fba12bebec..32aa114429 100644
--- a/Resources/Prototypes/Entities/Objects/Specific/Medical/morgue.yml
+++ b/Resources/Prototypes/Entities/Objects/Specific/Medical/morgue.yml
@@ -120,6 +120,8 @@
- type: SolutionSpiker
sourceSolution: food
ignoreEmpty: true
+ - type: ScoopableSolution
+ solution: food
- type: Extractable
grindableSolutionName: food