using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using Content.Shared.Chemistry.Components; using Content.Shared.Chemistry.Components.SolutionManager; using Content.Shared.Chemistry.Reaction; using Content.Shared.Chemistry.Reagent; using Content.Shared.Containers; using Content.Shared.Examine; using Content.Shared.FixedPoint; using Content.Shared.Hands.EntitySystems; using Content.Shared.Localizations; using Content.Shared.Nutrition.EntitySystems; using Content.Shared.Verbs; using JetBrains.Annotations; using Robust.Shared.ColorNaming; using Robust.Shared.Containers; using Robust.Shared.GameStates; using Robust.Shared.Network; using Robust.Shared.Prototypes; using Robust.Shared.Timing; using Robust.Shared.Utility; using Dependency = Robust.Shared.IoC.DependencyAttribute; namespace Content.Shared.Chemistry.EntitySystems; /// /// The event raised whenever a solution entity is modified. /// This event is raised on the owner of the solution. /// If the changed solution is contained in a , it will be raised on the owner of that component. /// /// /// Raised after chemcial reactions and are handled. /// This is always raised on the client when handling the component state so that we can update UIs accordingly. /// You might need an IGameTiming.ApplyingState guard to prevent mispredicts if the changes from your subscription are /// networked with the same game state. /// /// The solution entity that has been modified. [ByRefEvent] public readonly partial record struct SolutionChangedEvent(Entity Solution); /// /// The event raised whenever a solution entity is filled past its capacity. /// /// The solution that has overflowed and was removed. [ByRefEvent] public partial record struct SolutionOverflowEvent(Solution Overflow) { /// The amount by which the solution entity has been overfilled. public readonly Solution Overflow = Overflow; /// Whether any of the event handlers for this event have handled overflow behaviour. public bool Handled = false; } [ByRefEvent] public partial record struct SolutionAccessAttemptEvent(string SolutionName) { public bool Cancelled; } /// /// Part of Chemistry system deal with SolutionContainers /// [UsedImplicitly] public abstract partial class SharedSolutionContainerSystem : EntitySystem { public static readonly EntProtoId DefaultSolution = "Solution"; [Dependency] protected IGameTiming Timing = default!; [Dependency] protected INetManager Net = default!; [Dependency] protected ChemicalReactionSystem ChemicalReactionSystem = default!; [Dependency] protected ExamineSystemShared ExamineSystem = default!; [Dependency] protected OpenableSystem Openable = default!; [Dependency] protected SharedAppearanceSystem AppearanceSystem = default!; [Dependency] protected SharedContainerSystem ContainerSystem = default!; [Dependency] protected SharedHandsSystem Hands = default!; [Dependency] private ILocalizationManager _localization = default!; [Dependency] protected EntityQuery ContainedQuery = default!; [Dependency] protected EntityQuery SolutionQuery = default!; [Dependency] protected EntityQuery SolutionManagerQuery = default!; public override void Initialize() { base.Initialize(); InitializeRelays(); InitializeContainerManager(); SubscribeLocalEvent(OnSolutionGetState); SubscribeLocalEvent(OnSolutionHandleState); SubscribeLocalEvent(OnComponentInit); SubscribeLocalEvent(OnSolutionInit); SubscribeLocalEvent(OnSolutionShutdown); SubscribeLocalEvent(OnExamineSolution); SubscribeLocalEvent>(OnSolutionExaminableVerb); SubscribeLocalEvent(OnManagerInit); SubscribeLocalEvent(OnManagerShutdown); SubscribeLocalEvent(OnSolutionAdded); SubscribeLocalEvent(OnSolutionRemoved); } private void OnSolutionGetState(Entity ent, ref ComponentGetState args) { args.State = new SolutionComponentState(ent.Comp.Id, ent.Comp.Solution); } private void OnSolutionHandleState(Entity ent, ref ComponentHandleState args) { if (args.Current is not SolutionComponentState cast) return; ent.Comp.Solution = cast.Solution.Clone(); ent.Comp.Id = cast.Id; // Always raise the event on the client so that we can update UIs accordingly. var changedEv = new SolutionChangedEvent(ent); RaiseLocalEvent(ent, ref changedEv); if (!ContainedQuery.TryComp(ent, out var contained) || !SolutionManagerQuery.TryComp(contained.Container, out var manager)) return; manager.Solutions[ent.Comp.Id] = ent; } /// /// Attempts to resolve a solution associated with an entity. /// /// The entity that holdes the container the solution entity is in. /// The name of the solution entities container. /// A reference to a solution entity to load the associated solution entity into. Will be unchanged if not null. /// Returns the solution state of the solution entity. /// Whether the solution was successfully resolved. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool ResolveSolution(Entity entity, string name, [NotNullWhen(true)] ref Entity? solutionEnt, [NotNullWhen(true)] out Solution? solution) { if (!ResolveSolution(entity, name, ref solutionEnt)) { solution = null; return false; } solution = solutionEnt.Value.Comp.Solution; return true; } /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool ResolveSolution(Entity container, string name, [NotNullWhen(true)] ref Entity? entity) { if (entity is not null) { DebugTools.Assert(TryGetSolution(container, name, out var debugEnt) && debugEnt.Value.Owner == entity.Value.Owner); return true; } return TryGetSolution(container, name, out entity); } /// /// Attempts to fetch a solution entity associated with an entity. /// /// /// If the solution entity will be frequently accessed please use the equivalent /// /// method and cache the result. /// /// The entity the solution entity should be associated with. /// The name of the solution entity to fetch. /// Returns the solution entity that was fetched. /// Returns the solution state of the solution entity that was fetched. /// /// Should we print an error if the solution specified by name is missing /// public bool TryGetSolution( Entity entity, string name, [NotNullWhen(true)] out Entity? solutionEnt, [NotNullWhen(true)] out Solution? solution, bool errorOnMissing = false) { if (!TryGetSolution(entity, name, out solutionEnt, errorOnMissing: errorOnMissing)) { solution = null; return false; } solution = solutionEnt.Value.Comp.Solution; return true; } /// public bool TryGetSolution( Entity entity, string name, [NotNullWhen(true)] out Entity? solutionEnt, bool errorOnMissing = false) { // use connected container instead of entity from arguments, if it exists. solutionEnt = null; var ev = new GetConnectedContainerEvent(); RaiseLocalEvent(entity, ref ev); if (ev.ContainerEntity.HasValue) entity = ev.ContainerEntity.Value; if (SolutionQuery.TryComp(entity, out var comp) && comp.Id == name) { solutionEnt = (entity.Owner, comp); return true; } if (!SolutionManagerQuery.Resolve(entity, ref entity.Comp, errorOnMissing)) return false; if (entity.Comp.Solutions.TryGetValue(name, out var solution)) { var attemptEv = new SolutionAccessAttemptEvent(name); RaiseLocalEvent(entity, ref attemptEv); if (attemptEv.Cancelled) return false; solutionEnt = solution; return true; } if (errorOnMissing) Log.Error($"{ToPrettyString(entity)} does not have a solution with ID: {name}"); return false; } /// /// Version of TryGetSolution that doesn't take or return an entity. /// Used for prototypes. /// public bool TryGetSolution(EntProtoId entProtoId, string name, [NotNullWhen(true)] out Solution? solution, bool errorOnMissing = false) { solution = null; if (!ProtoMan.Resolve(entProtoId, out var proto)) return false; return TryGetSolution(proto, name, out solution, errorOnMissing); } public bool TryGetSolution(EntityPrototype entProto, string name, [NotNullWhen(true)] out Solution? solution, bool errorOnMissing = false) { solution = null; if (entProto.TryComp(out var sol, Factory) && sol.Id == name) { solution = sol.Solution; return true; } if (!TryGetSolutionFill(entProto, out var solutions)) return false; foreach (var protoId in solutions) { if (!ProtoMan.Resolve(protoId, out var proto)) continue; if (!proto.TryComp(out sol, Factory)) { Log.Error($"Entity prototype {proto}, tried to spawn in a solution container in prototype {entProto.ID}, but had no {nameof(SolutionComponent)}"); continue; } if (sol.Id != name) continue; solution = sol.Solution; return true; } if (errorOnMissing) Log.Error($"{entProto.ID} does not have a solution with ID: {name}"); return false; } public IEnumerable<(string? Name, Entity Solution)> EnumerateSolutions(Entity entity, bool includeSelf = true) { if (includeSelf && SolutionQuery.TryComp(entity, out var solutionComp)) yield return (solutionComp.Id, (entity.Owner, solutionComp)); if (!SolutionManagerQuery.Resolve(entity, ref entity.Comp, logMissing: false)) yield break; foreach (var (id, solution) in entity.Comp.Solutions) { var attemptEv = new SolutionAccessAttemptEvent(id); RaiseLocalEvent(entity, ref attemptEv); if (attemptEv.Cancelled) continue; yield return (id, solution); } } public IEnumerable<(string Id, Solution Solution)> EnumerateSolutions(EntityPrototype entProto) { if (!TryGetSolutionFill(entProto, out var solutions)) yield break; foreach (var protoId in solutions) { if (!ProtoMan.Resolve(protoId, out var proto)) continue; if (!proto.TryComp(out var sol, Factory)) { Log.Error($"Entity prototype {proto}, tried to spawn in a solution container in prototype {entProto.ID}, but had no {nameof(SolutionComponent)}"); continue; } yield return (sol.Id, sol.Solution); } } private bool TryGetSolutionFill(EntityPrototype entProto, [NotNullWhen(true)] out EntProtoId[]? fill) { fill = null; if (!entProto.TryComp(out var manager, Factory)) return false; fill = manager.SolutionEnts; return fill != null; } protected void UpdateAppearance(Entity container, Entity soln) { var (uid, appearanceComponent) = container; if (!HasComp(uid) || !Resolve(uid, ref appearanceComponent, logMissing: false)) return; var solution = soln.Comp.Solution; AppearanceSystem.SetData(uid, SolutionContainerVisuals.FillFraction, solution.FillFraction, appearanceComponent); AppearanceSystem.SetData(uid, SolutionContainerVisuals.Color, solution.GetColor(ProtoMan), appearanceComponent); AppearanceSystem.SetData(uid, SolutionContainerVisuals.SolutionName, soln.Comp.Id, appearanceComponent); if (solution.GetPrimaryReagentId() is { } reagent) AppearanceSystem.SetData(uid, SolutionContainerVisuals.BaseOverride, reagent.ToString(), appearanceComponent); } public FixedPoint2 GetTotalPrototypeQuantity(Entity owner, string reagentId) { var reagentQuantity = FixedPoint2.New(0); if (Exists(owner)) { foreach (var (_, solution) in EnumerateSolutions(owner)) { reagentQuantity += solution.Comp.Solution.GetTotalPrototypeQuantity(reagentId); } } return reagentQuantity; } /// /// Dirties a solution entity that has been modified and prompts updates to chemical reactions and overflow state. /// Should be invoked whenever a solution entity is modified. /// /// /// 90% of this system is ensuring that this proc is invoked whenever a solution entity is changed. The other 10% is this proc. /// /// /// /// public void UpdateChemicals(Entity solution, bool needsReactionsProcessing = true, ReactionMixerComponent? mixerComponent = null) { // Process reactions if (needsReactionsProcessing && solution.Comp.Solution.CanReact) ChemicalReactionSystem.FullyReactSolution(solution, mixerComponent); var overflow = solution.Comp.Solution.Volume - solution.Comp.Solution.MaxVolume; if (overflow > FixedPoint2.Zero) { var split = solution.Comp.Solution.SplitSolution(overflow); var overflowEv = new SolutionOverflowEvent(split); RaiseLocalEvent(solution, ref overflowEv); } var owner = GetSolutionOwner(solution); var changedEv = new SolutionChangedEvent(solution); RaiseLocalEvent(owner, ref changedEv); Dirty(solution); if (Timing.ApplyingState) return; UpdateAppearance(owner, solution); } public EntityUid GetSolutionOwner(Entity entity) { return ContainedQuery.CompOrNull(entity)?.Container ?? entity.Owner; } /// /// Removes part of the solution in the container. /// /// /// /// the volume of solution to remove. /// The solution that was removed. public Solution SplitSolution(Entity soln, FixedPoint2 quantity) { var (uid, comp) = soln; var solution = comp.Solution; var splitSol = solution.SplitSolution(quantity); UpdateChemicals(soln); return splitSol; } public Solution SplitStackSolution(Entity soln, FixedPoint2 quantity, int stackCount) { var (uid, comp) = soln; var solution = comp.Solution; var splitSol = solution.SplitSolution(quantity / stackCount); solution.SplitSolution(quantity - splitSol.Volume); UpdateChemicals(soln); return splitSol; } /// /// Splits a solution without the specified reagent(s). /// public Solution SplitSolutionWithout(Entity soln, FixedPoint2 quantity, params ProtoId[] reagents) { var (uid, comp) = soln; var solution = comp.Solution; var splitSol = solution.SplitSolutionWithout(quantity, reagents); UpdateChemicals(soln); return splitSol; } public void RemoveAllSolution(Entity soln) { var (uid, comp) = soln; var solution = comp.Solution; if (solution.Volume == 0) return; solution.RemoveAllSolution(); UpdateChemicals(soln); } /// /// 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(Entity soln, FixedPoint2 capacity) { var (uid, comp) = soln; var solution = comp.Solution; if (solution.MaxVolume == capacity) return; solution.MaxVolume = capacity; UpdateChemicals(soln); } /// /// Sets whether or not the given solution entity can react and dirties it. /// public void SetCanReact(Entity soln, bool canReact) { soln.Comp.Solution.CanReact = canReact; UpdateChemicals(soln); } /// /// 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(Entity soln, ReagentQuantity reagentQuantity, out FixedPoint2 acceptedQuantity, float? temperature = null) { var (uid, comp) = soln; var solution = comp.Solution; acceptedQuantity = solution.AvailableVolume > reagentQuantity.Quantity ? reagentQuantity.Quantity : solution.AvailableVolume; if (acceptedQuantity <= 0) return reagentQuantity.Quantity == 0; if (temperature == null) { solution.AddReagent(reagentQuantity.Reagent, acceptedQuantity); } else { var proto = ProtoMan.Index(reagentQuantity.Reagent.Prototype); solution.AddReagent(proto, acceptedQuantity, temperature.Value, ProtoMan); } UpdateChemicals(soln); 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. /// If all the reagent could be added. [PublicAPI] public bool TryAddReagent(Entity soln, string prototype, FixedPoint2 quantity, float? temperature = null, List? data = null) => TryAddReagent(soln, new ReagentQuantity(prototype, quantity, data), out _, temperature); /// /// 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(Entity soln, string prototype, FixedPoint2 quantity, out FixedPoint2 acceptedQuantity, float? temperature = null, List? data = null) { var reagent = new ReagentQuantity(prototype, quantity, data); return TryAddReagent(soln, 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(Entity soln, ReagentId reagentId, FixedPoint2 quantity, out FixedPoint2 acceptedQuantity, float? temperature = null) { var quant = new ReagentQuantity(reagentId, quantity); return TryAddReagent(soln, quant, out acceptedQuantity, temperature); } /// /// Removes reagent from a container. /// /// /// Solution container from which we are removing reagent. /// The reagent to remove. /// The amount of reagent that was removed. public FixedPoint2 RemoveReagent(Entity soln, ReagentQuantity reagentQuantity) { var (uid, comp) = soln; var solution = comp.Solution; var quant = solution.RemoveReagent(reagentQuantity); if (quant <= FixedPoint2.Zero) return FixedPoint2.Zero; UpdateChemicals(soln); return quant; } /// /// 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. /// The amount of reagent that was removed. public FixedPoint2 RemoveReagent(Entity soln, string prototype, FixedPoint2 quantity, List? data = null) { return RemoveReagent(soln, 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. /// The amount of reagent that was removed. public FixedPoint2 RemoveReagent(Entity soln, ReagentId reagentId, FixedPoint2 quantity) { return RemoveReagent(soln, 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(Entity soln, Solution source, FixedPoint2 quantity) { var (uid, comp) = soln; var solution = comp.Solution; if (quantity < 0) throw new InvalidOperationException("Quantity must be positive"); quantity = FixedPoint2.Min(quantity, solution.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. solution.AddSolution(source.SplitSolution(quantity), ProtoMan); UpdateChemicals(soln); return true; } /// /// Adds a solution to the container, if it can fully fit. /// /// Solution we are adding to /// solution being added /// If the solution could be added. public bool TryAddSolution(Entity solution, Solution toAdd) { if (toAdd.Volume == FixedPoint2.Zero) return true; if (toAdd.Volume > solution.Comp.Solution.AvailableVolume) return false; ForceAddSolution((solution, solution.Comp), toAdd); return true; } /// /// Adds as much of a solution to a container as can fit and updates the container. /// /// The entity containing /// The solution being added to. /// The solution being added to . This solution is not modified. /// The quantity of the solution actually added. public FixedPoint2 AddSolution(Entity soln, Solution toAdd) { var solution = soln.Comp.Solution; if (toAdd.Volume == FixedPoint2.Zero) return FixedPoint2.Zero; var quantity = FixedPoint2.Max(FixedPoint2.Zero, FixedPoint2.Min(toAdd.Volume, solution.AvailableVolume)); if (quantity < toAdd.Volume) { // TODO: This should be made into a function that directly transfers reagents. // Currently this is quite inefficient. solution.AddSolution(toAdd.Clone().SplitSolution(quantity), ProtoMan); } else solution.AddSolution(toAdd, ProtoMan); UpdateChemicals(soln); return quantity; } /// /// Adds a solution to a container and updates the container. /// This can exceed the maximum volume of the solution added to. /// /// The entity containing /// The solution being added to. /// The solution being added to . This solution is not modified. /// Whether any reagents were added to the solution. public bool ForceAddSolution(Entity soln, Solution toAdd) { var solution = soln.Comp.Solution; if (toAdd.Volume == FixedPoint2.Zero) return false; solution.AddSolution(toAdd, ProtoMan); UpdateChemicals(soln); 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(Entity soln, Solution toAdd, FixedPoint2 overflowThreshold, [MaybeNullWhen(false)] out Solution overflowingSolution) { var (uid, comp) = soln; var solution = comp.Solution; if (toAdd.Volume == 0 || overflowThreshold > solution.MaxVolume) { overflowingSolution = null; return false; } solution.AddSolution(toAdd, ProtoMan); overflowingSolution = solution.SplitSolution(FixedPoint2.Max(FixedPoint2.Zero, solution.Volume - overflowThreshold)); UpdateChemicals(soln); return true; } /// /// 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(Entity soln, FixedPoint2 quantity) { var (uid, comp) = soln; var solution = comp.Solution; 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(soln); return removedSolution; } // Thermal energy and temperature management. // TODO: ENERGY CONSERVATION!!! Nuke this once we have HeatContainers and use methods which properly conserve energy and model heat transfer correctly! #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(Entity soln, float temperature) { var (_, comp) = soln; var solution = comp.Solution; if (temperature == solution.Temperature) return; solution.Temperature = temperature; UpdateChemicals(soln); } /// /// 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(Entity soln, float thermalEnergy) { var (_, comp) = soln; var solution = comp.Solution; var heatCap = solution.GetHeatCapacity(ProtoMan); solution.Temperature = heatCap == 0 ? 0 : thermalEnergy / heatCap; UpdateChemicals(soln); } /// /// 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(Entity soln, float thermalEnergy) { var (_, comp) = soln; var solution = comp.Solution; if (thermalEnergy == 0.0f) return; var heatCap = solution.GetHeatCapacity(ProtoMan); solution.Temperature += heatCap == 0 ? 0 : thermalEnergy / heatCap; UpdateChemicals(soln); } /// /// Same as but clamps the value between two temperature values. /// /// Solution we're adjusting the energy of /// Thermal energy we're adding or removing /// Min desired temperature /// Max desired temperature public void AddThermalEnergyClamped(Entity soln, float thermalEnergy, float min, float max) { var solution = soln.Comp.Solution; if (thermalEnergy == 0.0f) return; var heatCap = solution.GetHeatCapacity(ProtoMan); var deltaT = thermalEnergy / heatCap; solution.Temperature = Math.Clamp(solution.Temperature + deltaT, min, max); UpdateChemicals(soln); } #endregion Thermal Energy and Temperature #region Event Handlers private void OnComponentInit(Entity entity, ref ComponentInit args) { entity.Comp.Solution.ValidateSolution(); } private void OnSolutionInit(Entity entity, ref MapInitEvent args) { UpdateChemicals(entity); } private void OnSolutionShutdown(Entity entity, ref ComponentShutdown args) { // If we are contained within another entity, update that entity. Otherwise, don't update if we're being deleted. if (ContainedQuery.HasComp(entity) || !Terminating(entity)) RemoveAllSolution(entity); } /// /// Shift click examine. /// private void OnExamineSolution(Entity entity, ref ExaminedEvent args) { if (!args.IsInDetailsRange || !CanSeeHiddenSolution(entity, args.Examiner) || !TryGetSolution(entity.Owner, entity.Comp.Solution, out _, out var solution)) return; using (args.PushGroup(nameof(ExaminableSolutionComponent))) { var primaryReagent = solution.GetPrimaryReagentId(); // If there's no primary reagent, assume the solution is empty and exit early if (string.IsNullOrEmpty(primaryReagent?.Prototype) || !ProtoMan.Resolve(primaryReagent.Value.Prototype, out var primary)) { args.PushMarkup(Loc.GetString(entity.Comp.LocVolume, ("fillLevel", ExaminedVolumeDisplay.Empty))); return; } // Push amount of reagent args.PushMarkup(Loc.GetString(entity.Comp.LocVolume, ("fillLevel", ExaminedVolume(entity, solution, args.Examiner)), ("current", solution.Volume), ("max", solution.MaxVolume))); // Push the physical description of the primary reagent var colorHex = solution.GetColor(ProtoMan) .ToHexNoAlpha(); //TODO: If the chem has a dark color, the examine text becomes black on a black background, which is unreadable. args.PushMarkup(Loc.GetString(entity.Comp.LocPhysicalQuality, ("color", colorHex), ("colorName", ColorNaming.Describe(solution.GetColor(ProtoMan), _localization)), ("desc", primary.LocalizedPhysicalDescription), ("chemCount", solution.Contents.Count))); // Push the recognizable reagents // Sort the reagents by amount, descending then alphabetically var sortedReagentPrototypes = solution.GetReagentPrototypes(ProtoMan) .OrderByDescending(pair => pair.Value.Value) .ThenBy(pair => pair.Key.LocalizedName); // Collect recognizable reagents, like water or beer var recognized = new List(); foreach (var keyValuePair in sortedReagentPrototypes) { var proto = keyValuePair.Key; if (!proto.Recognizable) { continue; } recognized.Add(Loc.GetString("examinable-solution-recognized", ("color", proto.SubstanceColor.ToHexNoAlpha()), ("chemical", proto.LocalizedName))); } if (recognized.Count == 0) return; var msg = ContentLocalizationManager.FormatList(recognized); // Finally push the full message args.PushMarkup(Loc.GetString(entity.Comp.LocRecognizableReagents, ("recognizedString", msg))); } } /// An enum for how to display the solution. public ExaminedVolumeDisplay ExaminedVolume(Entity ent, Solution sol, EntityUid? examiner = null) { // Exact measurement if (ent.Comp.ExactVolume) return ExaminedVolumeDisplay.Exact; // General approximation return (int)PercentFull(sol) switch { 100 => ExaminedVolumeDisplay.Full, > 66 => ExaminedVolumeDisplay.MostlyFull, > 33 => HalfEmptyOrHalfFull(examiner), > 0 => ExaminedVolumeDisplay.MostlyEmpty, _ => ExaminedVolumeDisplay.Empty, }; } // Some spessmen see half full, some see half empty, but always the same one. private ExaminedVolumeDisplay HalfEmptyOrHalfFull(EntityUid? examiner = null) { // Optimistic when un-observed if (examiner == null) return ExaminedVolumeDisplay.HalfFull; var meta = MetaData(examiner.Value); if (meta.EntityName.Length > 0 && string.Compare(meta.EntityName.Substring(0, 1), "m", StringComparison.InvariantCultureIgnoreCase) > 0) return ExaminedVolumeDisplay.HalfFull; return ExaminedVolumeDisplay.HalfEmpty; } /// /// Full reagent scan, such as with chemical analysis goggles. /// private void OnSolutionExaminableVerb(Entity entity, ref GetVerbsEvent args) { if (!args.CanInteract || !args.CanAccess) return; var scanEvent = new SolutionScanEvent(); RaiseLocalEvent(args.User, scanEvent); if (!scanEvent.CanScan) { return; } if (!TryGetSolution(args.Target, entity.Comp.Solution, out _, out var solutionHolder)) { return; } if (!CanSeeHiddenSolution(entity, args.User)) return; var target = args.Target; var user = args.User; var verb = new ExamineVerb() { Act = () => { var markup = GetSolutionExamine(solutionHolder); ExamineSystem.SendExamineTooltip(user, target, 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.AddMarkupOrThrow(Loc.GetString("scannable-solution-empty-container")); return msg; } msg.AddMarkupOrThrow(Loc.GetString("scannable-solution-main-text")); var reagentPrototypes = solution.GetReagentPrototypes(ProtoMan); // Sort the reagents by amount, descending then alphabetically var sortedReagentPrototypes = reagentPrototypes .OrderByDescending(pair => pair.Value.Value) .ThenBy(pair => pair.Key.LocalizedName); foreach (var (proto, quantity) in sortedReagentPrototypes) { msg.PushNewline(); msg.AddMarkupOrThrow(Loc.GetString("scannable-solution-chemical" , ("type", proto.LocalizedName) , ("color", proto.SubstanceColor.ToHexNoAlpha()) , ("amount", quantity))); } msg.PushNewline(); msg.AddMarkupOrThrow(Loc.GetString("scannable-solution-temperature", ("temperature", Math.Round(solution.Temperature)))); return msg; } /// /// Check if an examinable solution is hidden by something. /// private bool CanSeeHiddenSolution(Entity entity, EntityUid examiner) { // If not held-only then it's always visible. if (entity.Comp.HeldOnly && !Hands.IsHolding(examiner, entity, out _)) return false; if (!entity.Comp.ExaminableWhileClosed && Openable.IsClosed(entity.Owner, predicted: true)) return false; return true; } private void OnManagerInit(Entity entity, ref MapInitEvent args) { InitializeManager(entity); } private void InitializeManager(Entity entity) { var container = ContainerSystem.EnsureContainer(entity.Owner, entity.Comp.Container); if (entity.Comp.SolutionEnts == null) return; foreach (var solution in entity.Comp.SolutionEnts) { CreateSolution(solution, container); } entity.Comp.SolutionEnts = null; } private void OnManagerShutdown(Entity entity, ref ComponentShutdown args) { if (ContainerSystem.TryGetContainer(entity, entity.Comp.Container, out var solutionContainer)) ContainerSystem.ShutdownContainer(solutionContainer); } private void OnSolutionAdded(Entity entity, ref EntInsertedIntoContainerMessage args) { // Container networking boilerplate if (args.Container.ID != entity.Comp.Container || !SolutionQuery.TryComp(args.Entity, out var solution)) return; // Don't add a solution entity with the same id as this entity's solution if it exists! DebugTools.Assert(!TryComp(entity, out var sol) || sol.Id != solution.Id, $"Tried to add a solution {MetaData(args.Entity).EntityPrototype} {solution.Id} to {ToPrettyString(entity)} but it itself was a solution with a matching id!"); EnsureComp(args.Entity, out var contained); contained.Container = entity.Owner; // Throw if we already have a solution with the same ID. // We only check on server as we actually want the server to bulldoze any client entities being cached when they come in. // Applying state, and first time predicted checks will cause mispredicts until the solution updates DebugTools.Assert(!entity.Comp.Solutions.TryGetValue(solution.Id, out var existing) || existing.Owner == args.Entity || Net.IsClient, $"Solution {ToPrettyString(entity)}, tried to add a solution {ToPrettyString(args.Entity)} with a duplicate id: {solution.Id} {ToPrettyString(existing)}"); entity.Comp.Solutions[solution.Id] = (args.Entity, solution); } private void OnSolutionRemoved(Entity entity, ref EntRemovedFromContainerMessage args) { // Container networking jank if (args.Container.ID != entity.Comp.Container || !SolutionQuery.TryComp(args.Entity, out var solution)) return; RemComp(args.Entity); entity.Comp.Solutions.Remove(solution.Id); } #endregion Event Handlers /// /// A method which ensures a solution with a given ID exists. /// /// Entity we're trying to attach a new solution to. /// Name of the new solution. /// Solution entity found or created. /// Returns true if the solution already existed, and false if it had to create a new solution. /// /// Only run this after the entity is already initialized. /// If you're running this when your entity is created, it is HIGHLY recommended to run on /// Deviance from these instructions may prevent your game from building. YOU HAVE BEEN WARNED. /// [Obsolete("Solution string matching will be removed in the future in favor of relations and enumerators.")] public bool EnsureSolution( Entity entity, string name, out Entity solutionEntity) { // Do NOT ever EnsureSolution during anything other than MapInit!!! DebugTools.Assert(LifeStage(entity) == EntityLifeStage.MapInitialized); if (SolutionQuery.TryComp(entity, out var comp) && comp.Id == name) { solutionEntity = (entity.Owner, comp); return true; } // Ensure we have a SolutionManagerComponent // EnsureComp should ensure a container and fill that container with default spawns! if (entity.Comp != null || EnsureComp(entity, out entity.Comp)) { // Component ensuring this solution initialized before our SolutionManager, so we run the initialization method now. if (entity.Comp.SolutionEnts != null) InitializeManager((entity, entity.Comp)); // Check the cache first, even if the component didn't exist before, creating one may have spawned and cached solutions! if (entity.Comp.Solutions.TryGetValue(name, out var solution)) { solutionEntity = solution; return true; } } // Create a default entity if one doesn't already exist! solutionEntity = CreateDefaultSolution((entity, entity.Comp), name); return false; } /// This is private since you should really be specifying a solution prototype to create. private Entity CreateDefaultSolution( Entity entity, string name) { var container = ContainerSystem.EnsureContainer(entity.Owner, entity.Comp.Container); return CreateDefaultSolution(name, container); } private Entity CreateDefaultSolution( string name, Container container) { var solution = SpawnSolutionUninitialized(DefaultSolution); solution.Comp.Id = name; ContainerSystem.Insert(solution.Owner, container, force: true); EntityManager.InitializeAndStartEntity(solution); FlagPredicted(solution.Owner); return solution; } public Entity CreateSolution( EntProtoId proto, Container container) { // TODO: Replace this with an engine bound method when e#6192 is merged. var solution = SpawnSolutionUninitialized(proto); ContainerSystem.Insert(solution.Owner, container, force: true); EntityManager.InitializeAndStartEntity(solution); FlagPredicted(solution.Owner); return solution; } private Entity SpawnSolutionUninitialized(EntProtoId solution) { var uid = EntityManager.CreateEntityUninitialized(solution); // If you pass in a ProtoId without a SolutionComponent that's your own damn fault! var comp = SolutionQuery.Comp(uid); return (uid, comp); } public void AdjustDissolvedReagent( Entity dissolvedSolution, FixedPoint2 volume, ReagentId reagent, float concentrationChange) { if (concentrationChange == 0) return; var dissolvedSol = dissolvedSolution.Comp.Solution; var amtChange = GetReagentQuantityFromConcentration(dissolvedSolution, volume, MathF.Abs(concentrationChange)); if (concentrationChange > 0) { dissolvedSol.AddReagent(reagent, amtChange); } else { dissolvedSol.RemoveReagent(reagent, amtChange); } UpdateChemicals(dissolvedSolution); } public FixedPoint2 GetReagentQuantityFromConcentration(Entity dissolvedSolution, FixedPoint2 volume, float concentration) { var dissolvedSol = dissolvedSolution.Comp.Solution; if (volume == 0 || dissolvedSol.Volume == 0) return 0; return concentration * volume; } public float GetReagentConcentration(Entity dissolvedSolution, FixedPoint2 volume, ReagentId dissolvedReagent) { var dissolvedSol = dissolvedSolution.Comp.Solution; if (volume == 0 || dissolvedSol.Volume == 0 || !dissolvedSol.TryGetReagentQuantity(dissolvedReagent, out var dissolvedVol)) return 0; return (float)dissolvedVol / volume.Float(); } public FixedPoint2 ClampReagentAmountByConcentration( Entity dissolvedSolution, FixedPoint2 volume, ReagentId dissolvedReagent, FixedPoint2 dissolvedReagentAmount, float maxConcentration = 1f) { var dissolvedSol = dissolvedSolution.Comp.Solution; if (volume == 0 || dissolvedSol.Volume == 0 || !dissolvedSol.TryGetReagentQuantity(dissolvedReagent, out var dissolvedVol)) return 0; volume *= maxConcentration; dissolvedVol += dissolvedReagentAmount; var overflow = volume - dissolvedVol; if (overflow < 0) dissolvedReagentAmount += overflow; return dissolvedReagentAmount; } }