using System.Diagnostics.CodeAnalysis; using Content.Shared.Administration.Logs; using Content.Shared.Chemistry.Components; using Content.Shared.Database; using Content.Shared.DoAfter; using Content.Shared.FixedPoint; using Content.Shared.Interaction; using Content.Shared.Popups; using Content.Shared.Verbs; using Robust.Shared.Serialization; namespace Content.Shared.Chemistry.EntitySystems; /// /// Allows an entity to transfer solutions with a customizable amount -per click-. /// Also provides , and API for other systems. /// public sealed class SolutionTransferSystem : EntitySystem { [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; [Dependency] private readonly SharedSolutionContainerSystem _solution = default!; [Dependency] private readonly SharedUserInterfaceSystem _ui = default!; [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; private EntityQuery _refillableQuery; private EntityQuery _drainableQuery; /// /// 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(OnTransferAmountSetValueMessage); SubscribeLocalEvent(OnAfterInteract); SubscribeLocalEvent(OnSolutionDrainTransferDoAfter); SubscribeLocalEvent(OnSolutionFillTransferDoAfter); _refillableQuery = GetEntityQuery(); _drainableQuery = GetEntityQuery(); } private void AddSetTransferVerbs(Entity ent, ref GetVerbsEvent args) { if (!args.CanAccess || !args.CanInteract || !ent.Comp.CanChangeTransferAmount || args.Hands == null) return; // Custom transfer verb var @event = args; 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 = () => { _ui.OpenUi(ent.Owner, TransferAmountUiKey.Key, @event.User); }, Priority = 1 }); // Add specific transfer verbs according to the container's size var priority = 0; var user = args.User; foreach (var amount in DefaultTransferAmounts) { if (amount < ent.Comp.MinimumTransferAmount || amount > ent.Comp.MaximumTransferAmount) continue; AlternativeVerb verb = new(); verb.Text = Loc.GetString("comp-solution-transfer-verb-amount", ("amount", amount)); verb.Category = VerbCategory.SetTransferAmount; verb.Act = () => { ent.Comp.TransferAmount = amount; _popup.PopupClient(Loc.GetString("comp-solution-transfer-set-amount", ("amount", amount)), ent.Owner, user); Dirty(ent.Owner, ent.Comp); }; // we want to sort by size, not alphabetically by the verb text. verb.Priority = priority; priority--; args.Verbs.Add(verb); } } 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.Actor is { Valid: true } user) _popup.PopupEntity(Loc.GetString("comp-solution-transfer-set-amount", ("amount", newTransferAmount)), ent.Owner, user); Dirty(ent.Owner, ent.Comp); } private void OnAfterInteract(Entity ent, ref AfterInteractEvent args) { if (!args.CanReach || args.Target is not {} target) return; // We have two cases for interaction: // Held Drainable --> Target Refillable // Held Refillable <-- Target Drainable // In the case where the target has both Refillable and Drainable, Held --> Target takes priority. if (ent.Comp.CanSend && _drainableQuery.TryComp(ent.Owner, out var heldDrainable) && _refillableQuery.TryComp(target, out var targetRefillable) && TryGetTransferrableSolutions((ent.Owner, heldDrainable), (target, targetRefillable), out var ownerSoln, out var targetSoln, out _)) { args.Handled = true; //If we reach this point, the interaction counts as handled. var transferAmount = ent.Comp.TransferAmount; if (targetRefillable.MaxRefill is {} maxRefill) transferAmount = FixedPoint2.Min(transferAmount, maxRefill); var transferData = new SolutionTransferData(args.User, ent.Owner, ownerSoln.Value, target, targetSoln.Value, transferAmount); var transferTime = targetRefillable.RefillTime + heldDrainable.DrainTime; if (transferTime > TimeSpan.Zero) { if (!CanTransfer(transferData)) return; var doAfterArgs = new DoAfterArgs(EntityManager, args.User, transferTime, new SolutionDrainTransferDoAfterEvent(transferAmount), ent.Owner, target) { BreakOnDamage = true, BreakOnMove = true, NeedHand = true, Hidden = true, }; _doAfter.TryStartDoAfter(doAfterArgs); } else { DrainTransfer(transferData); } return; } if (ent.Comp.CanReceive && _refillableQuery.TryComp(ent.Owner, out var heldRefillable) && _drainableQuery.TryComp(target, out var targetDrainable) && TryGetTransferrableSolutions((target, targetDrainable), (ent.Owner, heldRefillable), out targetSoln, out ownerSoln, out var solution)) { args.Handled = true; //If we reach this point, the interaction counts as handled. var transferAmount = ent.Comp.TransferAmount; // This is the player-configurable transfer amount of "uid," not the target drainable. if (heldRefillable.MaxRefill is {} maxRefill) // if the receiver has a smaller transfer limit, use that instead transferAmount = FixedPoint2.Min(transferAmount, maxRefill); var transferData = new SolutionTransferData(args.User, target, targetSoln.Value, ent.Owner, ownerSoln.Value, transferAmount); var transferTime = heldRefillable.RefillTime + targetDrainable.DrainTime; if (transferTime > TimeSpan.Zero) { if (!CanTransfer(transferData)) return; var doAfterArgs = new DoAfterArgs(EntityManager, args.User, transferTime, new SolutionRefillTransferDoAfterEvent(transferAmount), ent.Owner, target) { BreakOnDamage = true, BreakOnMove = true, NeedHand = true, Hidden = true, }; _doAfter.TryStartDoAfter(doAfterArgs); } else { RefillTransfer(transferData, solution); } } } private void OnSolutionDrainTransferDoAfter(Entity ent, ref SolutionDrainTransferDoAfterEvent args) { if (args.Cancelled || args.Target is not { } target) return; // Have to check again, in case something has changed. if (CanSend(ent, target, out var ownerSoln, out var targetSoln)) { DrainTransfer(new SolutionTransferData(args.User, ent.Owner, ownerSoln.Value, args.Target.Value, targetSoln.Value, args.Amount)); } } private void OnSolutionFillTransferDoAfter(Entity ent, ref SolutionRefillTransferDoAfterEvent args) { if (args.Cancelled || args.Target is not { } target) return; // Have to check again, in case something has changed. if (!CanRecieve(ent, target, out var ownerSoln, out var targetSoln, out var solution)) return; RefillTransfer(new SolutionTransferData(args.User, target, targetSoln.Value, ent.Owner, ownerSoln.Value, args.Amount), solution); } private bool CanSend(Entity ent, Entity target, [NotNullWhen(true)] out Entity? drainable, [NotNullWhen(true)] out Entity? refillable) { drainable = null; refillable = null; return ent.Comp1.CanReceive && TryGetTransferrableSolutions(ent.Owner, target, out drainable, out refillable, out _); } private bool CanRecieve(Entity ent, EntityUid source, [NotNullWhen(true)] out Entity? drainable, [NotNullWhen(true)] out Entity? refillable, [NotNullWhen(true)] out Solution? solution) { drainable = null; refillable = null; solution = null; return ent.Comp.CanReceive && TryGetTransferrableSolutions(source, ent.Owner, out drainable, out refillable, out solution); } private bool TryGetTransferrableSolutions(Entity source, Entity target, [NotNullWhen(true)] out Entity? drainable, [NotNullWhen(true)] out Entity? refillable, [NotNullWhen(true)] out Solution? solution) { drainable = null; refillable = null; solution = null; if (!_drainableQuery.Resolve(source, ref source.Comp) || !_refillableQuery.Resolve(target, ref target.Comp)) return false; if (!_solution.TryGetDrainableSolution(source, out drainable, out _)) return false; if (!_solution.TryGetRefillableSolution(target, out refillable, out solution)) return false; return true; } /// /// Attempt to drain a solution into another, such as pouring a bottle into a glass. /// Includes a pop-up if the transfer failed or succeeded /// /// The transfer data making up the transfer. /// The actual amount transferred. private void DrainTransfer(SolutionTransferData data) { var transferred = Transfer(data); if (transferred <= 0) return; var message = Loc.GetString("comp-solution-transfer-transfer-solution", ("amount", transferred), ("target", data.TargetEntity)); _popup.PopupClient(message, data.SourceEntity, data.User); } /// /// Attempt to fill a solution from another container, such as tapping from a water tank. /// Includes a pop-up if the transfer failed or succeeded. /// /// The transfer data making up the transfer. /// The target solution,included for LoC pop-up purposes. /// The actual amount transferred. private void RefillTransfer(SolutionTransferData data, Solution targetSolution) { var transferred = Transfer(data); if (transferred <= 0) return; var toTheBrim = targetSolution.AvailableVolume == 0; var msg = toTheBrim ? "comp-solution-transfer-fill-fully" : "comp-solution-transfer-fill-normal"; _popup.PopupClient(Loc.GetString(msg, ("owner", data.SourceEntity), ("amount", transferred), ("target", data.TargetEntity)), data.TargetEntity, data.User); } /// /// Transfer from a solution to another, allowing either entity to cancel. /// Includes a pop-up if the transfer failed. /// /// The actual amount transferred. public FixedPoint2 Transfer(SolutionTransferData data) { var sourceSolution = data.Source.Comp.Solution; var targetSolution = data.Target.Comp.Solution; if (!CanTransfer(data)) return FixedPoint2.Zero; var actualAmount = FixedPoint2.Min(data.Amount, FixedPoint2.Min(sourceSolution.Volume, targetSolution.AvailableVolume)); var solution = _solution.SplitSolution(data.Source, actualAmount); _solution.AddSolution(data.Target, solution); var ev = new SolutionTransferredEvent(data.SourceEntity, data.TargetEntity, data.User, actualAmount); RaiseLocalEvent(data.TargetEntity, ref ev); _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(data.User):player} transferred {SharedSolutionContainerSystem.ToPrettyString(solution)} to {ToPrettyString(data.TargetEntity):target}, which now contains {SharedSolutionContainerSystem.ToPrettyString(targetSolution)}"); return actualAmount; } /// /// Check if the source solution can transfer the amount to the target solution, and display a pop-up if it fails. /// private bool CanTransfer(SolutionTransferData data) { var transferAttempt = new SolutionTransferAttemptEvent(data.SourceEntity, data.TargetEntity); // Check if the source is cancelling the transfer RaiseLocalEvent(data.SourceEntity, ref transferAttempt); if (transferAttempt.CancelReason is {} reason) { _popup.PopupClient(reason, data.SourceEntity, data.User); return false; } var sourceSolution = data.Source.Comp.Solution; if (sourceSolution.Volume == 0) { _popup.PopupClient(Loc.GetString("comp-solution-transfer-is-empty", ("target", data.SourceEntity)), data.SourceEntity, data.User); return false; } // Check if the target is cancelling the transfer RaiseLocalEvent(data.TargetEntity, ref transferAttempt); if (transferAttempt.CancelReason is {} targetReason) { _popup.PopupClient(targetReason, data.TargetEntity, data.User); return false; } var targetSolution = data.Target.Comp.Solution; if (targetSolution.AvailableVolume == 0) { _popup.PopupClient(Loc.GetString("comp-solution-transfer-is-full", ("target", data.TargetEntity)), data.TargetEntity, data.User); return false; } return true; } } /// /// A collection of data containing relevant entities and values for transferring reagents. /// /// The user performing the transfer. /// The entity holding the solution container which reagents are being moved from. /// The entity holding the solution from which reagents are being moved away from. /// The entity holding the solution container which reagents are being moved to. /// The entity holding the solution which reagents are being moved to /// The amount being moved. public struct SolutionTransferData(EntityUid user, EntityUid sourceEntity, Entity source, EntityUid targetEntity, Entity target, FixedPoint2 amount) { public EntityUid User = user; public EntityUid SourceEntity = sourceEntity; public Entity Source = source; public EntityUid TargetEntity = targetEntity; public Entity Target = target; public FixedPoint2 Amount = amount; } /// /// 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; } } /// /// Raised on the target entity when a non-zero amount of solution gets transferred. /// [ByRefEvent] public record struct SolutionTransferredEvent(EntityUid From, EntityUid To, EntityUid User, FixedPoint2 Amount); /// /// Doafter event for solution transfers where the held item is drained into the target. Checks for validity both when initiating and when finishing the event. /// [Serializable, NetSerializable] public sealed partial class SolutionDrainTransferDoAfterEvent : DoAfterEvent { public FixedPoint2 Amount; public SolutionDrainTransferDoAfterEvent(FixedPoint2 amount) { Amount = amount; } public override DoAfterEvent Clone() => this; } /// /// Doafter event for solution transfers where the held item is filled from the target. Checks for validity both when initiating and when finishing the event. /// [Serializable, NetSerializable] public sealed partial class SolutionRefillTransferDoAfterEvent : DoAfterEvent { public FixedPoint2 Amount; public SolutionRefillTransferDoAfterEvent(FixedPoint2 amount) { Amount = amount; } public override DoAfterEvent Clone() => this; }