using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using Content.Shared.Chemistry.Components; using Content.Shared.Chemistry.Components.SolutionManager; using Content.Shared.Chemistry.Reaction; using Content.Shared.Chemistry.Reagent; using Content.Shared.Examine; using Content.Shared.FixedPoint; using Content.Shared.Verbs; using JetBrains.Annotations; using Robust.Shared.Audio; using Robust.Shared.Prototypes; using Robust.Shared.Utility; namespace Content.Shared.Chemistry.EntitySystems; /// /// This event alerts system that the solution was changed /// public sealed class SolutionChangedEvent : EntityEventArgs { public readonly Solution Solution; public readonly string SolutionId; public SolutionChangedEvent(Solution solution, string solutionId) { SolutionId = solutionId; Solution = solution; } } /// /// An event raised when more reagents are added to a (managed) solution than it can hold. /// [ByRefEvent] public record struct SolutionOverflowEvent(EntityUid SolutionEnt, Solution SolutionHolder, Solution Overflow) { /// The entity which contains the solution that has overflowed. public readonly EntityUid SolutionEnt = SolutionEnt; /// The solution that has overflowed. public readonly Solution SolutionHolder = SolutionHolder; /// The reagents that have overflowed the solution. public readonly Solution Overflow = Overflow; /// The volume by which the solution has overflowed. public readonly FixedPoint2 OverflowVol = Overflow.Volume; /// Whether some subscriber has taken care of the effects of the overflow. public bool Handled = false; } /// /// Part of Chemistry system deal with SolutionContainers /// [UsedImplicitly] public sealed partial class SolutionContainerSystem : EntitySystem { [Dependency] private readonly ChemicalReactionSystem _chemistrySystem = default!; [Dependency] private readonly SharedAppearanceSystem _appearance = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly ExamineSystemShared _examine = default!; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(InitSolution); SubscribeLocalEvent(OnExamineSolution); SubscribeLocalEvent>(OnSolutionExaminableVerb); } private void InitSolution(EntityUid uid, SolutionContainerManagerComponent component, ComponentInit args) { foreach (var (name, solutionHolder) in component.Solutions) { solutionHolder.Name = name; solutionHolder.ValidateSolution(); UpdateAppearance(uid, solutionHolder); } } private void OnSolutionExaminableVerb(EntityUid uid, ExaminableSolutionComponent component, GetVerbsEvent args) { if (!args.CanInteract || !args.CanAccess) return; var scanEvent = new SolutionScanEvent(); RaiseLocalEvent(args.User, scanEvent); if (!scanEvent.CanScan) { return; } SolutionContainerManagerComponent? solutionsManager = null; if (!Resolve(args.Target, ref solutionsManager) || !solutionsManager.Solutions.TryGetValue(component.Solution, out var solutionHolder)) { return; } var verb = new ExamineVerb() { Act = () => { var markup = GetSolutionExamine(solutionHolder); _examine.SendExamineTooltip(args.User, uid, markup, false, false); }, Text = Loc.GetString("scannable-solution-verb-text"), Message = Loc.GetString("scannable-solution-verb-message"), Category = VerbCategory.Examine, Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/drink.svg.192dpi.png")), }; args.Verbs.Add(verb); } private FormattedMessage GetSolutionExamine(Solution solution) { var msg = new FormattedMessage(); if (solution.Volume == 0) { msg.AddMarkup(Loc.GetString("scannable-solution-empty-container")); return msg; } msg.AddMarkup(Loc.GetString("scannable-solution-main-text")); foreach (var (proto, quantity) in solution.GetReagentPrototypes(_prototypeManager)) { msg.PushNewline(); msg.AddMarkup(Loc.GetString("scannable-solution-chemical" , ("type", proto.LocalizedName) , ("color", proto.SubstanceColor.ToHexNoAlpha()) , ("amount", quantity))); } return msg; } private void OnExamineSolution(EntityUid uid, ExaminableSolutionComponent examinableComponent, ExaminedEvent args) { SolutionContainerManagerComponent? solutionsManager = null; if (!Resolve(args.Examined, ref solutionsManager) || !solutionsManager.Solutions.TryGetValue(examinableComponent.Solution, out var solution)) { return; } var primaryReagent = solution.GetPrimaryReagentId(); if (string.IsNullOrEmpty(primaryReagent?.Prototype)) { args.PushText(Loc.GetString("shared-solution-container-component-on-examine-empty-container")); return; } if (!_prototypeManager.TryIndex(primaryReagent.Value.Prototype, out ReagentPrototype? primary)) { Log.Error($"{nameof(Solution)} could not find the prototype associated with {primaryReagent}."); return; } var colorHex = solution.GetColor(_prototypeManager) .ToHexNoAlpha(); //TODO: If the chem has a dark color, the examine text becomes black on a black background, which is unreadable. var messageString = "shared-solution-container-component-on-examine-main-text"; args.PushMarkup(Loc.GetString(messageString, ("color", colorHex), ("wordedAmount", Loc.GetString(solution.Contents.Count == 1 ? "shared-solution-container-component-on-examine-worded-amount-one-reagent" : "shared-solution-container-component-on-examine-worded-amount-multiple-reagents")), ("desc", primary.LocalizedPhysicalDescription))); // Add descriptions of immediately recognizable reagents, like water or beer var recognized = new List(); foreach (var proto in solution.GetReagentPrototypes(_prototypeManager).Keys) { if (!proto.Recognizable) { continue; } recognized.Add(proto); } // Skip if there's nothing recognizable if (recognized.Count == 0) return; var msg = new StringBuilder(); foreach (var reagent in recognized) { string part; if (reagent == recognized[0]) { part = "examinable-solution-recognized-first"; } else if (reagent == recognized[^1]) { // this loc specifically requires space to be appended, fluent doesnt support whitespace msg.Append(' '); part = "examinable-solution-recognized-last"; } else { part = "examinable-solution-recognized-next"; } msg.Append(Loc.GetString(part, ("color", reagent.SubstanceColor.ToHexNoAlpha()), ("chemical", reagent.LocalizedName))); } args.PushMarkup(Loc.GetString("examinable-solution-has-recognizable-chemicals", ("recognizedString", msg.ToString()))); } public void UpdateAppearance(EntityUid uid, Solution solution, AppearanceComponent? appearanceComponent = null) { if (!EntityManager.EntityExists(uid) || !Resolve(uid, ref appearanceComponent, false)) return; _appearance.SetData(uid, SolutionContainerVisuals.FillFraction, solution.FillFraction, appearanceComponent); _appearance.SetData(uid, SolutionContainerVisuals.Color, solution.GetColor(_prototypeManager), appearanceComponent); if (solution.Name != null) { _appearance.SetData(uid, SolutionContainerVisuals.SolutionName, solution.Name, appearanceComponent); } if (solution.GetPrimaryReagentId() is { } reagent) { _appearance.SetData(uid, SolutionContainerVisuals.BaseOverride, reagent.ToString(), appearanceComponent); } else { _appearance.SetData(uid, SolutionContainerVisuals.BaseOverride, string.Empty, appearanceComponent); } } /// /// Removes part of the solution in the container. /// /// /// /// the volume of solution to remove. /// The solution that was removed. public Solution SplitSolution(EntityUid targetUid, Solution solutionHolder, FixedPoint2 quantity) { var splitSol = solutionHolder.SplitSolution(quantity); UpdateChemicals(targetUid, solutionHolder); return splitSol; } public Solution SplitStackSolution(EntityUid targetUid, Solution solutionHolder, FixedPoint2 quantity, int stackCount) { var splitSol = solutionHolder.SplitSolution(quantity / stackCount); solutionHolder.SplitSolution(quantity - splitSol.Volume); UpdateChemicals(targetUid, solutionHolder); return splitSol; } /// /// Splits a solution without the specified reagent(s). /// public Solution SplitSolutionWithout(EntityUid targetUid, Solution solutionHolder, FixedPoint2 quantity, params string[] reagents) { var splitSol = solutionHolder.SplitSolutionWithout(quantity, reagents); UpdateChemicals(targetUid, solutionHolder); return splitSol; } public void UpdateChemicals(EntityUid uid, Solution solutionHolder, bool needsReactionsProcessing = false, ReactionMixerComponent? mixerComponent = null) { DebugTools.Assert(solutionHolder.Name != null && TryGetSolution(uid, solutionHolder.Name, out var tmp) && tmp == solutionHolder); // Process reactions if (needsReactionsProcessing && solutionHolder.CanReact) { _chemistrySystem.FullyReactSolution(solutionHolder, uid, solutionHolder.MaxVolume, mixerComponent); } var overflowVol = solutionHolder.Volume - solutionHolder.MaxVolume; if (overflowVol > FixedPoint2.Zero) { var overflow = solutionHolder.SplitSolution(overflowVol); var overflowEv = new SolutionOverflowEvent(uid, solutionHolder, overflow); RaiseLocalEvent(uid, ref overflowEv); } UpdateAppearance(uid, solutionHolder); RaiseLocalEvent(uid, new SolutionChangedEvent(solutionHolder, solutionHolder.Name)); } public void RemoveAllSolution(EntityUid uid, Solution solutionHolder) { if (solutionHolder.Volume == 0) return; solutionHolder.RemoveAllSolution(); UpdateChemicals(uid, solutionHolder); } public void RemoveAllSolution(EntityUid uid, SolutionContainerManagerComponent? solutionContainerManager = null) { if (!Resolve(uid, ref solutionContainerManager)) return; foreach (var solution in solutionContainerManager.Solutions.Values) { RemoveAllSolution(uid, solution); } } /// /// Sets the capacity (maximum volume) of a solution to a new value. /// /// The entity containing the solution. /// The solution to set the capacity of. /// The value to set the capacity of the solution to. public void SetCapacity(EntityUid targetUid, Solution targetSolution, FixedPoint2 capacity) { if (targetSolution.MaxVolume == capacity) return; targetSolution.MaxVolume = capacity; if (capacity < targetSolution.Volume) targetSolution.RemoveSolution(targetSolution.Volume - capacity); UpdateChemicals(targetUid, targetSolution); } /// /// Adds reagent of an Id to the container. /// /// /// Container to which we are adding reagent /// The reagent to add. /// The amount of reagent successfully added. /// If all the reagent could be added. public bool TryAddReagent(EntityUid targetUid, Solution targetSolution, ReagentQuantity reagentQuantity, out FixedPoint2 acceptedQuantity, float? temperature = null) { acceptedQuantity = targetSolution.AvailableVolume > reagentQuantity.Quantity ? reagentQuantity.Quantity : targetSolution.AvailableVolume; if (acceptedQuantity <= 0) return reagentQuantity.Quantity == 0; if (temperature == null) { targetSolution.AddReagent(reagentQuantity.Reagent, acceptedQuantity); } else { var proto = _prototypeManager.Index(reagentQuantity.Reagent.Prototype); targetSolution.AddReagent(proto, acceptedQuantity, temperature.Value, _prototypeManager); } UpdateChemicals(targetUid, targetSolution, true); return acceptedQuantity == reagentQuantity.Quantity; } /// /// Adds reagent of an Id to the container. /// /// /// Container to which we are adding reagent /// The Id of the reagent to add. /// The amount of reagent to add. /// The amount of reagent successfully added. /// If all the reagent could be added. public bool TryAddReagent(EntityUid targetUid, Solution targetSolution, string prototype, FixedPoint2 quantity, out FixedPoint2 acceptedQuantity, float? temperature = null, ReagentData? data = null) { var reagent = new ReagentQuantity(prototype, quantity, data); return TryAddReagent(targetUid, targetSolution, reagent, out acceptedQuantity, temperature); } /// /// Adds reagent of an Id to the container. /// /// /// Container to which we are adding reagent /// The reagent to add. /// The amount of reagent to add. /// The amount of reagent successfully added. /// If all the reagent could be added. public bool TryAddReagent(EntityUid targetUid, Solution targetSolution, ReagentId reagentId, FixedPoint2 quantity, out FixedPoint2 acceptedQuantity, float? temperature = null) { var quant = new ReagentQuantity(reagentId, quantity); return TryAddReagent(targetUid, targetSolution, quant, out acceptedQuantity, temperature); } /// /// Removes reagent from a container. /// /// /// Solution container from which we are removing reagent /// The reagent to remove. /// If the reagent to remove was found in the container. public bool RemoveReagent(EntityUid targetUid, Solution? container, ReagentQuantity reagentQuantity) { if (container == null) return false; var quant = container.RemoveReagent(reagentQuantity); if (quant <= FixedPoint2.Zero) return false; UpdateChemicals(targetUid, container); return true; } /// /// Removes reagent from a container. /// /// /// Solution container from which we are removing reagent /// The Id of the reagent to remove. /// The amount of reagent to remove. /// If the reagent to remove was found in the container. public bool RemoveReagent(EntityUid targetUid, Solution? container, string prototype, FixedPoint2 quantity, ReagentData? data = null) { return RemoveReagent(targetUid, container, new ReagentQuantity(prototype, quantity, data)); } /// /// Removes reagent from a container. /// /// /// Solution container from which we are removing reagent /// The reagent to remove. /// The amount of reagent to remove. /// If the reagent to remove was found in the container. public bool RemoveReagent(EntityUid targetUid, Solution? container, ReagentId reagentId, FixedPoint2 quantity) { return RemoveReagent(targetUid, container, new ReagentQuantity(reagentId, quantity)); } /// /// Moves some quantity of a solution from one solution to another. /// /// entity holding the source solution /// entity holding the target solution /// source solution /// target solution /// quantity of solution to move from source to target. If this is a negative number, the source & target roles are reversed. public bool TryTransferSolution(EntityUid sourceUid, EntityUid targetUid, Solution source, Solution target, FixedPoint2 quantity) { if (!TryTransferSolution(targetUid, target, source, quantity)) return false; UpdateChemicals(sourceUid, source, false); return true; } /// /// Moves some quantity of a solution from one solution to another. /// /// entity holding the source solution /// entity holding the target solution /// source solution /// target solution /// quantity of solution to move from source to target. If this is a negative number, the source & target roles are reversed. public bool TryTransferSolution(EntityUid targetUid, Solution target, Solution source, FixedPoint2 quantity) { if (quantity < 0) throw new InvalidOperationException("Quantity must be positive"); quantity = FixedPoint2.Min(quantity, target.AvailableVolume, source.Volume); if (quantity == 0) return false; // TODO This should be made into a function that directly transfers reagents. // Currently this is quite inefficient. target.AddSolution(source.SplitSolution(quantity), _prototypeManager); UpdateChemicals(targetUid, target, true); return true; } /// /// Moves some quantity of a solution from one solution to another. /// /// entity holding the source solution /// entity holding the target solution /// source solution /// target solution /// quantity of solution to move from source to target. If this is a negative number, the source & target roles are reversed. public bool TryTransferSolution(EntityUid sourceUid, EntityUid targetUid, string source, string target, FixedPoint2 quantity) { if (!TryGetSolution(sourceUid, source, out var sourceSoln)) return false; if (!TryGetSolution(targetUid, target, out var targetSoln)) return false; return TryTransferSolution(sourceUid, targetUid, sourceSoln, targetSoln, quantity); } /// /// Adds a solution to the container, if it can fully fit. /// /// entity holding targetSolution /// entity holding targetSolution /// solution being added /// If the solution could be added. public bool TryAddSolution(EntityUid targetUid, Solution targetSolution, Solution toAdd) { if (toAdd.Volume == FixedPoint2.Zero) return true; if (toAdd.Volume > targetSolution.AvailableVolume) return false; ForceAddSolution(targetUid, targetSolution, toAdd); return true; } /// /// Adds as much of a solution to a container as can fit. /// /// The entity containing /// The solution being added to. /// The solution being added to /// The quantity of the solution actually added. public FixedPoint2 AddSolution(EntityUid targetUid, Solution targetSolution, Solution toAdd) { if (toAdd.Volume == FixedPoint2.Zero) return FixedPoint2.Zero; var quantity = FixedPoint2.Max(FixedPoint2.Zero, FixedPoint2.Min(toAdd.Volume, targetSolution.AvailableVolume)); if (quantity < toAdd.Volume) TryTransferSolution(targetUid, targetSolution, toAdd, quantity); else ForceAddSolution(targetUid, targetSolution, toAdd); return quantity; } /// /// Adds a solution to a container and updates the container. /// /// The entity containing /// The solution being added to. /// The solution being added to /// Whether any reagents were added to the solution. public bool ForceAddSolution(EntityUid targetUid, Solution targetSolution, Solution toAdd) { if (toAdd.Volume == FixedPoint2.Zero) return false; targetSolution.AddSolution(toAdd, _prototypeManager); UpdateChemicals(targetUid, targetSolution, needsReactionsProcessing: true); return true; } /// /// Adds a solution to the container, removing the overflow. /// Unlike it will ignore size limits. /// /// The entity containing /// The solution being added to. /// The solution being added to /// The combined volume above which the overflow will be returned. /// If the combined volume is below this an empty solution is returned. /// Solution that exceeded overflowThreshold /// Whether any reagents were added to . public bool TryMixAndOverflow(EntityUid targetUid, Solution targetSolution, Solution toAdd, FixedPoint2 overflowThreshold, [NotNullWhen(true)] out Solution? overflowingSolution) { if (toAdd.Volume == 0 || overflowThreshold > targetSolution.MaxVolume) { overflowingSolution = null; return false; } targetSolution.AddSolution(toAdd, _prototypeManager); overflowingSolution = targetSolution.SplitSolution(FixedPoint2.Max(FixedPoint2.Zero, targetSolution.Volume - overflowThreshold)); UpdateChemicals(targetUid, targetSolution, true); return true; } public bool TryGetSolution([NotNullWhen(true)] EntityUid? uid, string name, [NotNullWhen(true)] out Solution? solution, SolutionContainerManagerComponent? solutionsMgr = null) { if (uid == null || !Resolve(uid.Value, ref solutionsMgr, false)) { solution = null; return false; } return solutionsMgr.Solutions.TryGetValue(name, out solution); } /// /// Will ensure a solution is added to given entity even if it's missing solutionContainerManager /// /// EntityUid to which to add solution /// name for the solution /// solution components used in resolves /// true if the solution already existed /// solution public Solution EnsureSolution(EntityUid uid, string name, out bool existed, SolutionContainerManagerComponent? solutionsMgr = null) { if (!Resolve(uid, ref solutionsMgr, false)) { solutionsMgr = EntityManager.EnsureComponent(uid); } if (!solutionsMgr.Solutions.TryGetValue(name, out var existing)) { var newSolution = new Solution() { Name = name }; solutionsMgr.Solutions.Add(name, newSolution); existed = false; return newSolution; } existed = true; return existing; } /// /// Will ensure a solution is added to given entity even if it's missing solutionContainerManager /// /// EntityUid to which to add solution /// name for the solution /// solution components used in resolves /// solution public Solution EnsureSolution(EntityUid uid, string name, SolutionContainerManagerComponent? solutionsMgr = null) => EnsureSolution(uid, name, out _, solutionsMgr); /// /// Will ensure a solution is added to given entity even if it's missing solutionContainerManager /// /// EntityUid to which to add solution /// name for the solution /// Ensures that the solution's maximum volume is larger than this value. /// solution components used in resolves /// solution public Solution EnsureSolution(EntityUid uid, string name, FixedPoint2 minVol, out bool existed, SolutionContainerManagerComponent? solutionsMgr = null) { if (!Resolve(uid, ref solutionsMgr, false)) { solutionsMgr = EntityManager.EnsureComponent(uid); } if (!solutionsMgr.Solutions.TryGetValue(name, out var existing)) { var newSolution = new Solution() { Name = name }; solutionsMgr.Solutions.Add(name, newSolution); existed = false; newSolution.MaxVolume = minVol; return newSolution; } existed = true; existing.MaxVolume = FixedPoint2.Max(existing.MaxVolume, minVol); return existing; } public Solution EnsureSolution(EntityUid uid, string name, IEnumerable reagents, bool setMaxVol = true, SolutionContainerManagerComponent? solutionsMgr = null) { if (!Resolve(uid, ref solutionsMgr, false)) solutionsMgr = EntityManager.EnsureComponent(uid); if (!solutionsMgr.Solutions.TryGetValue(name, out var existing)) { var newSolution = new Solution(reagents, setMaxVol); solutionsMgr.Solutions.Add(name, newSolution); return newSolution; } existing.SetContents(reagents, setMaxVol); return existing; } /// /// Removes an amount from all reagents in a solution, adding it to a new solution. /// /// The entity containing the solution. /// The solution to remove reagents from. /// The amount to remove from every reagent in the solution. /// A new solution containing every removed reagent from the original solution. public Solution RemoveEachReagent(EntityUid uid, Solution solution, FixedPoint2 quantity) { if (quantity <= 0) return new Solution(); var removedSolution = new Solution(); // RemoveReagent does a RemoveSwap, meaning we don't have to copy the list if we iterate it backwards. for (var i = solution.Contents.Count - 1; i >= 0; i--) { var (reagent, _) = solution.Contents[i]; var removedQuantity = solution.RemoveReagent(reagent, quantity); removedSolution.AddReagent(reagent, removedQuantity); } UpdateChemicals(uid, solution); return removedSolution; } public FixedPoint2 GetTotalPrototypeQuantity(EntityUid owner, string reagentId) { var reagentQuantity = FixedPoint2.New(0); if (EntityManager.EntityExists(owner) && EntityManager.TryGetComponent(owner, out SolutionContainerManagerComponent? managerComponent)) { foreach (var solution in managerComponent.Solutions.Values) { reagentQuantity += solution.GetTotalPrototypeQuantity(reagentId); } } return reagentQuantity; } public bool TryGetMixableSolution(EntityUid uid, [NotNullWhen(true)] out Solution? solution, SolutionContainerManagerComponent? solutionsMgr = null) { if (!Resolve(uid, ref solutionsMgr, false)) { solution = null; return false; } var getMixableSolutionAttempt = new GetMixableSolutionAttemptEvent(uid); RaiseLocalEvent(uid, ref getMixableSolutionAttempt); if (getMixableSolutionAttempt.MixedSolution != null) { solution = getMixableSolutionAttempt.MixedSolution; return true; } var tryGetSolution = solutionsMgr.Solutions.FirstOrNull(x => x.Value.CanMix); if (tryGetSolution.HasValue) { solution = tryGetSolution.Value.Value; return true; } solution = null; return false; } /// /// Gets the most common reagent across all solutions by volume. /// /// public ReagentPrototype? GetMaxReagent(SolutionContainerManagerComponent component) { if (component.Solutions.Count == 0) return null; var reagentCounts = new Dictionary(); foreach (var solution in component.Solutions.Values) { foreach (var (reagent, quantity) in solution.Contents) { reagentCounts.TryGetValue(reagent, out var existing); existing += quantity; reagentCounts[reagent] = existing; } } var max = reagentCounts.Max(); return _prototypeManager.Index(max.Key.Prototype); } public SoundSpecifier? GetSound(SolutionContainerManagerComponent component) { var max = GetMaxReagent(component); return max?.FootstepSound; } // Thermal energy and temperature management. #region Thermal Energy and Temperature /// /// Sets the temperature of a solution to a new value and then checks for reaction processing. /// /// The entity in which the solution is located. /// The solution to set the temperature of. /// The new value to set the temperature to. public void SetTemperature(EntityUid owner, Solution solution, float temperature) { if (temperature == solution.Temperature) return; solution.Temperature = temperature; UpdateChemicals(owner, solution, true); } /// /// Sets the thermal energy of a solution to a new value and then checks for reaction processing. /// /// The entity in which the solution is located. /// The solution to set the thermal energy of. /// The new value to set the thermal energy to. public void SetThermalEnergy(EntityUid owner, Solution solution, float thermalEnergy) { var heatCap = solution.GetHeatCapacity(_prototypeManager); solution.Temperature = heatCap == 0 ? 0 : thermalEnergy / heatCap; UpdateChemicals(owner, solution, true); } /// /// Adds some thermal energy to a solution and then checks for reaction processing. /// /// The entity in which the solution is located. /// The solution to set the thermal energy of. /// The new value to set the thermal energy to. public void AddThermalEnergy(EntityUid owner, Solution solution, float thermalEnergy) { if (thermalEnergy == 0.0f) return; var heatCap = solution.GetHeatCapacity(_prototypeManager); solution.Temperature += heatCap == 0 ? 0 : thermalEnergy / heatCap; UpdateChemicals(owner, solution, true); } #endregion Thermal Energy and Temperature #region Event Handlers #endregion Event Handlers }