]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
scoopable ash and foam, solution transfer prediction (#25832)
authordeltanedas <39013340+deltanedas@users.noreply.github.com>
Mon, 1 Apr 2024 06:27:39 +0000 (06:27 +0000)
committerGitHub <noreply@github.com>
Mon, 1 Apr 2024 06:27:39 +0000 (17:27 +1100)
* move SolutionTransfer to shared and predict as much as possible

* fully move OpenableSystem to shared now that SolutionTransfer is

* fix imports for everything

* doc for solution transfer system

* trolling

* add scoopable system

* make ash and foam scoopable

* untroll

* untroll real

* make clickable it work

* troll

* the scooping room

---------

Co-authored-by: deltanedas <@deltanedas:kde.org>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
23 files changed:
Content.Client/Nutrition/EntitySystems/OpenableSystem.cs [deleted file]
Content.Server/Chemistry/EntitySystems/ReagentDispenserSystem.cs
Content.Server/Chemistry/EntitySystems/SolutionTransferSystem.cs [deleted file]
Content.Server/Destructible/Thresholds/Behaviors/OpenBehavior.cs
Content.Server/Extinguisher/FireExtinguisherSystem.cs
Content.Server/Fluids/EntitySystems/PuddleSystem.Spillable.cs
Content.Server/Glue/GlueSystem.cs
Content.Server/Lube/LubeSystem.cs
Content.Server/Materials/MaterialReclaimerSystem.cs
Content.Server/NPC/Systems/NPCUtilitySystem.cs
Content.Server/Nutrition/EntitySystems/DrinkSystem.cs
Content.Server/Nutrition/EntitySystems/FoodSystem.cs
Content.Server/Nutrition/EntitySystems/OpenableSystem.cs [deleted file]
Content.Shared/Chemistry/Components/ScoopableSolutionComponent.cs [new file with mode: 0644]
Content.Shared/Chemistry/EntitySystems/ScoopableSolutionSystem.cs [new file with mode: 0644]
Content.Shared/Chemistry/EntitySystems/SolutionTransferSystem.cs [new file with mode: 0644]
Content.Shared/Fluids/SharedPuddleSystem.Spillable.cs
Content.Shared/Nutrition/Components/OpenableComponent.cs
Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs [moved from Content.Shared/Nutrition/EntitySystems/SharedOpenableSystem.cs with 91% similarity]
Content.Shared/Nutrition/EntitySystems/SealableSystem.cs
Resources/Locale/en-US/chemistry/components/scoopable-component.ftl [new file with mode: 0644]
Resources/Prototypes/Entities/Effects/chemistry_effects.yml
Resources/Prototypes/Entities/Objects/Specific/Medical/morgue.yml

diff --git a/Content.Client/Nutrition/EntitySystems/OpenableSystem.cs b/Content.Client/Nutrition/EntitySystems/OpenableSystem.cs
deleted file mode 100644 (file)
index f8c3f7c..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-using Content.Shared.Nutrition.EntitySystems;
-
-namespace Content.Client.Nutrition.EntitySystems;
-
-public sealed class OpenableSystem : SharedOpenableSystem
-{
-}
index a8583e6bcb3fcbfea32504c2e96965efdec4a7b8..d6433da56a0f92dc55e5b1ff5702045c878a3e7d 100644 (file)
@@ -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 (file)
index 1ed5cec..0000000
+++ /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!;
-
-        /// <summary>
-        ///     Default transfer amounts for the set-transfer verb.
-        /// </summary>
-        public static readonly List<int> DefaultTransferAmounts = new() { 1, 5, 10, 25, 50, 100, 250, 500, 1000 };
-
-        public override void Initialize()
-        {
-            base.Initialize();
-
-            SubscribeLocalEvent<SolutionTransferComponent, GetVerbsEvent<AlternativeVerb>>(AddSetTransferVerbs);
-            SubscribeLocalEvent<SolutionTransferComponent, AfterInteractEvent>(OnAfterInteract);
-            SubscribeLocalEvent<SolutionTransferComponent, TransferAmountSetValueMessage>(OnTransferAmountSetValueMessage);
-        }
-
-        private void OnTransferAmountSetValueMessage(Entity<SolutionTransferComponent> 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<SolutionTransferComponent> entity, ref GetVerbsEvent<AlternativeVerb> 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<SolutionTransferComponent> 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<RefillableSolutionComponent>(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;
-                }
-            }
-        }
-
-        /// <summary>
-        /// Transfer from a solution to another.
-        /// </summary>
-        /// <returns>The actual amount transferred.</returns>
-        public FixedPoint2 Transfer(EntityUid user,
-            EntityUid sourceEntity,
-            Entity<SolutionComponent> source,
-            EntityUid targetEntity,
-            Entity<SolutionComponent> 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;
-        }
-    }
-
-    /// <summary>
-    /// Raised when attempting to transfer from one solution to another.
-    /// </summary>
-    public sealed class SolutionTransferAttemptEvent : CancellableEntityEventArgs
-    {
-        public SolutionTransferAttemptEvent(EntityUid from, EntityUid to)
-        {
-            From = from;
-            To = to;
-        }
-
-        public EntityUid From { get; }
-        public EntityUid To { get; }
-
-        /// <summary>
-        /// Why the transfer has been cancelled.
-        /// </summary>
-        public string? CancelReason { get; private set; }
-
-        /// <summary>
-        /// Cancels the transfer.
-        /// </summary>
-        public void Cancel(string reason)
-        {
-            base.Cancel();
-            CancelReason = reason;
-        }
-    }
-}
index f01e4f7048ebf68660b1bde97d59c1ea376bdf1c..7ab1fe11b01f31605cabc5e9c1865a47b07cec1f 100644 (file)
@@ -1,4 +1,4 @@
-using Content.Server.Nutrition.EntitySystems;
+using Content.Shared.Nutrition.EntitySystems;
 
 namespace Content.Server.Destructible.Thresholds.Behaviors;
 
index dfecd72398dc8abd9ff2c0d32ce11ade51c1f769..b33a1af157fd7228f38fed6bdae318b9a63c272a 100644 (file)
@@ -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<SolutionTransferComponent>(entity.Owner, out var solTrans))
         {
index a365b8d0a45266ffa3d3350610da2395ed0bb412..bd7c55e85ea51afd600ef6a2c7600b573ec11d40 100644 (file)
@@ -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;
index 44ff4e54593deed629059886a70e91bbe45230d2..ff53ef91cac6cc845d35b91b7581ae3e8a7e7e29 100644 (file)
@@ -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;
index 5285cb389c5e5b20d0c22c5f4d6fb022a8a56ab1..06d6456a57c32a023dc4af94fe07ac7af4e246e9 100644 (file)
@@ -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;
index ae4444e059b1640a3e1b2ae2d73769ffded60e39..0d6d27777a43bbec7ee4b8a891c56e78c4345259 100644 (file)
@@ -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;
index 6793161105e323421a2cbdfc84ec7e651a0966ff..e8fb54022ee1f6a993ae6530aa1e7c40c8b29d7d 100644 (file)
@@ -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;
index 036c855dbbac1ec2d481c70a768e26416b217cd1..74637d4813709fab565dc7505b7aa8eed24e15e2 100644 (file)
@@ -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;
index d87b0bd0b02ec657d10913670075652fe9fb2696..49d737404127c10551921746a6f89b265a11c00c 100644 (file)
@@ -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 (file)
index 8037b61..0000000
+++ /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;
-
-/// <summary>
-/// Provides API for openable food and drinks, handles opening on use and preventing transfer when closed.
-/// </summary>
-public sealed class OpenableSystem : SharedOpenableSystem
-{
-    public override void Initialize()
-    {
-        base.Initialize();
-
-        SubscribeLocalEvent<OpenableComponent, SolutionTransferAttemptEvent>(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 (file)
index 0000000..6c3f934
--- /dev/null
@@ -0,0 +1,31 @@
+using Content.Shared.Chemistry.EntitySystems;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Chemistry.Components;
+
+/// <summary>
+/// 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.
+/// </summary>
+[RegisterComponent, NetworkedComponent, Access(typeof(ScoopableSolutionSystem))]
+public sealed partial class ScoopableSolutionComponent : Component
+{
+    /// <summary>
+    /// Solution name that can be scooped from.
+    /// </summary>
+    [DataField]
+    public string Solution = "default";
+
+    /// <summary>
+    /// If true, when the whole solution is scooped up the entity will be deleted.
+    /// </summary>
+    [DataField]
+    public bool Delete = true;
+
+    /// <summary>
+    /// Popup to show the user when scooping.
+    /// Passed entities "scooped" and "beaker".
+    /// </summary>
+    [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 (file)
index 0000000..84f1e45
--- /dev/null
@@ -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;
+
+/// <summary>
+/// Handles solution transfer when a beaker is used on a scoopable entity.
+/// </summary>
+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<ScoopableSolutionComponent, InteractUsingEvent>(OnInteractUsing);
+    }
+
+    private void OnInteractUsing(Entity<ScoopableSolutionComponent> ent, ref InteractUsingEvent args)
+    {
+        TryScoop(ent, args.Used, args.User);
+    }
+
+    public bool TryScoop(Entity<ScoopableSolutionComponent> 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<ScoopableSolutionComponent>(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 (file)
index 0000000..34a64d0
--- /dev/null
@@ -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;
+
+/// <summary>
+/// Allows an entity to transfer solutions with a customizable amount per click.
+/// Also provides <see cref="Transfer"/> API for other systems.
+/// </summary>
+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!;
+
+    /// <summary>
+    ///     Default transfer amounts for the set-transfer verb.
+    /// </summary>
+    public static readonly FixedPoint2[] DefaultTransferAmounts = new FixedPoint2[] { 1, 5, 10, 25, 50, 100, 250, 500, 1000 };
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<SolutionTransferComponent, GetVerbsEvent<AlternativeVerb>>(AddSetTransferVerbs);
+        SubscribeLocalEvent<SolutionTransferComponent, AfterInteractEvent>(OnAfterInteract);
+        SubscribeLocalEvent<SolutionTransferComponent, TransferAmountSetValueMessage>(OnTransferAmountSetValueMessage);
+    }
+
+    private void OnTransferAmountSetValueMessage(Entity<SolutionTransferComponent> 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<SolutionTransferComponent> ent, ref GetVerbsEvent<AlternativeVerb> args)
+    {
+        var (uid, comp) = ent;
+
+        if (!args.CanAccess || !args.CanInteract || !comp.CanChangeTransferAmount || args.Hands == null)
+            return;
+
+        if (!TryComp<ActorComponent>(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<SolutionTransferComponent> 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<RefillableSolutionComponent>(target) // target must not be refillable (e.g. Reagent Tanks)
+            && _solution.TryGetDrainableSolution(target, out var targetSoln, out _) // target must be drainable
+            && TryComp<RefillableSolutionComponent>(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<RefillableSolutionComponent>(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;
+            }
+        }
+    }
+
+    /// <summary>
+    /// Transfer from a solution to another, allowing either entity to cancel it and show a popup.
+    /// </summary>
+    /// <returns>The actual amount transferred.</returns>
+    public FixedPoint2 Transfer(EntityUid user,
+        EntityUid sourceEntity,
+        Entity<SolutionComponent> source,
+        EntityUid targetEntity,
+        Entity<SolutionComponent> 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;
+    }
+}
+
+/// <summary>
+/// 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.
+/// </summary>
+[ByRefEvent]
+public record struct SolutionTransferAttemptEvent(EntityUid From, EntityUid To, string? CancelReason = null)
+{
+    /// <summary>
+    /// Cancels the transfer.
+    /// </summary>
+    public void Cancel(string reason)
+    {
+        CancelReason = reason;
+    }
+}
index 92ea96214013015708b7f2ef25ebefe54db48c9d..f88f13e8b0d77ae4c3f4b3cab0d4e72d5a210874 100644 (file)
@@ -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()
     {
index 3a230fc765dd5d3a9f0c99c503ab7dd26e2b42f2..0381888e28244b86f6f13084aeadebe360a5a689 100644 (file)
@@ -9,7 +9,7 @@ namespace Content.Shared.Nutrition.Components;
 /// Starts closed, open it with Z or E.
 /// </summary>
 [NetworkedComponent, AutoGenerateComponentState]
-[RegisterComponent, Access(typeof(SharedOpenableSystem))]
+[RegisterComponent, Access(typeof(OpenableSystem))]
 public sealed partial class OpenableComponent : Component
 {
     /// <summary>
similarity index 91%
rename from Content.Shared/Nutrition/EntitySystems/SharedOpenableSystem.cs
rename to Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs
index f3b1127578d93969579d98cf823a0bf25b66e6e8..0ad0877d2220e84954ff6cd37a624ae20d090ec9 100644 (file)
@@ -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;
 /// <summary>
 /// Provides API for openable food and drinks, handles opening on use and preventing transfer when closed.
 /// </summary>
-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<OpenableComponent, MeleeHitEvent>(HandleIfClosed);
         SubscribeLocalEvent<OpenableComponent, AfterInteractEvent>(HandleIfClosed);
         SubscribeLocalEvent<OpenableComponent, GetVerbsEvent<Verb>>(AddOpenCloseVerbs);
+        SubscribeLocalEvent<OpenableComponent, SolutionTransferAttemptEvent>(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<OpenableComponent> 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)));
+        }
+    }
+
     /// <summary>
     /// 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.
index b0873f23a1291d3aa7422c078f192c947ee5d9c3..414b8d182b0d028ba69f514aa543c6e61ff6b494 100644 (file)
@@ -11,7 +11,7 @@ public sealed partial class SealableSystem : EntitySystem
     {
         base.Initialize();
 
-        SubscribeLocalEvent<SealableComponent, ExaminedEvent>(OnExamined, after: new[] { typeof(SharedOpenableSystem) });
+        SubscribeLocalEvent<SealableComponent, ExaminedEvent>(OnExamined, after: new[] { typeof(OpenableSystem) });
         SubscribeLocalEvent<SealableComponent, OpenableOpenedEvent>(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 (file)
index 0000000..c2593cc
--- /dev/null
@@ -0,0 +1 @@
+scoopable-component-popup = You scoop up {$scooped} into {THE($beaker)}.
index 739464e9611f90e1da5e446be4fa11c49afc61e7..5b7ee46946ee2e1b03d8f5a39e7872510f52d2a5 100644 (file)
@@ -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
index fba12bebec41bdde7dcf14e10aa29dc8c7047411..32aa114429de047c6fa0423e1beef6f3926d4b84 100644 (file)
   - type: SolutionSpiker
     sourceSolution: food
     ignoreEmpty: true
+  - type: ScoopableSolution
+    solution: food
   - type: Extractable
     grindableSolutionName: food