Solution rejig (#12428)

This commit is contained in:
Leon Friedrich
2023-01-12 16:41:40 +13:00
committed by GitHub
parent 38504f6412
commit 466384b081
61 changed files with 873 additions and 619 deletions

View File

@@ -1,12 +1,13 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Shared.Chemistry.Reagent;
using Content.Shared.FixedPoint;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
using Robust.Shared.Utility;
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace Content.Shared.Chemistry.Components
{
@@ -17,7 +18,8 @@ namespace Content.Shared.Chemistry.Components
[DataDefinition]
public sealed partial class Solution : IEnumerable<Solution.ReagentQuantity>, ISerializationHooks
{
// Most objects on the station hold only 1 or 2 reagents
// This is a list because it is actually faster to add and remove reagents from
// a list than a dictionary, though contains-reagent checks are slightly slower,
[DataField("reagents")]
public List<ReagentQuantity> Contents = new(2);
@@ -25,7 +27,41 @@ namespace Content.Shared.Chemistry.Components
/// The calculated total volume of all reagents in the solution (ex. Total volume of liquid in beaker).
/// </summary>
[ViewVariables]
public FixedPoint2 TotalVolume { get; set; }
public FixedPoint2 Volume { get; set; }
/// <summary>
/// Maximum volume this solution supports.
/// </summary>
/// <remarks>
/// A value of zero means the maximum will automatically be set equal to the current volume during
/// initialization. Note that most solution methods ignore max volume altogether, but various solution
/// systems use this.
/// </remarks>
[DataField("maxVol")]
[ViewVariables(VVAccess.ReadWrite)]
public FixedPoint2 MaxVolume { get; set; } = FixedPoint2.Zero;
public float FillFraction => MaxVolume == 0 ? 1 : Volume.Float() / MaxVolume.Float();
/// <summary>
/// If reactions will be checked for when adding reagents to the container.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("canReact")]
public bool CanReact { get; set; } = true;
/// <summary>
/// If reactions can occur via mixing.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("canMix")]
public bool CanMix { get; set; } = false;
/// <summary>
/// Volume needed to fill this container.
/// </summary>
[ViewVariables]
public FixedPoint2 AvailableVolume => MaxVolume - Volume;
/// <summary>
/// The temperature of the reagents in the solution.
@@ -34,40 +70,155 @@ namespace Content.Shared.Chemistry.Components
[DataField("temperature")]
public float Temperature { get; set; } = 293.15f;
public Color Color => GetColor();
/// <summary>
/// The name of this solution, if it is contained in some <see cref="SolutionContainerManagerComponent"/>
/// </summary>
public string? Name;
/// <summary>
/// Checks if a solution can fit into the container.
/// </summary>
public bool CanAddSolution(Solution solution)
{
return solution.Volume <= AvailableVolume;
}
/// <summary>
/// The total heat capacity of all reagents in the solution.
/// </summary>
[ViewVariables]
private float _heatCapacity;
/// <summary>
/// If true, then <see cref="_heatCapacity"/> needs to be recomputed.
/// </summary>
[ViewVariables]
private bool _heatCapacityDirty = true;
public void UpdateHeatCapacity(IPrototypeManager? protoMan)
{
IoCManager.Resolve(ref protoMan);
DebugTools.Assert(_heatCapacityDirty);
_heatCapacityDirty = false;
_heatCapacity = 0;
foreach (var reagent in Contents)
{
_heatCapacity += (float) reagent.Quantity * protoMan.Index<ReagentPrototype>(reagent.ReagentId).SpecificHeat;
}
}
public float GetHeatCapacity(IPrototypeManager? protoMan)
{
if (_heatCapacityDirty)
UpdateHeatCapacity(protoMan);
return _heatCapacity;
}
/// <summary>
/// Constructs an empty solution (ex. an empty beaker).
/// </summary>
public Solution() { }
public Solution() : this(2) // Most objects on the station hold only 1 or 2 reagents.
{
}
/// <summary>
/// Constructs an empty solution (ex. an empty beaker).
/// </summary>
public Solution(int capacity)
{
Contents = new(capacity);
}
/// <summary>
/// Constructs a solution containing 100% of a reagent (ex. A beaker of pure water).
/// </summary>
/// <param name="reagentId">The prototype ID of the reagent to add.</param>
/// <param name="quantity">The quantity in milli-units.</param>
public Solution(string reagentId, FixedPoint2 quantity)
public Solution(string reagentId, FixedPoint2 quantity) : this()
{
AddReagent(reagentId, quantity);
}
public Solution(IEnumerable<ReagentQuantity> reagents, bool setMaxVol = true)
{
Contents = new(reagents);
Volume = FixedPoint2.Zero;
foreach (var reagent in Contents)
{
Volume += reagent.Quantity;
}
if (setMaxVol)
MaxVolume = Volume;
ValidateSolution();
}
public Solution(Solution solution)
{
Volume = solution.Volume;
_heatCapacity = solution._heatCapacity;
_heatCapacityDirty = solution._heatCapacityDirty;
Contents = solution.Contents.ShallowClone();
ValidateSolution();
}
public Solution Clone()
{
return new Solution(this);
}
[AssertionMethod]
public void ValidateSolution()
{
// sandbox forbids: [Conditional("DEBUG")]
#if DEBUG
// Correct volume
DebugTools.Assert(Contents.Select(x => x.Quantity).Sum() == Volume);
// All reagents have at least some reagent present.
DebugTools.Assert(!Contents.Any(x => x.Quantity <= FixedPoint2.Zero));
// No duplicate reagent iDs
DebugTools.Assert(Contents.Select(x => x.ReagentId).ToHashSet().Count() == Contents.Count);
// If it isn't flagged as dirty, check heat capacity is correct.
if (!_heatCapacityDirty)
{
var cur = _heatCapacity;
_heatCapacityDirty = true;
UpdateHeatCapacity(null);
DebugTools.Assert(MathHelper.CloseTo(_heatCapacity, cur));
}
#endif
}
void ISerializationHooks.AfterDeserialization()
{
TotalVolume = FixedPoint2.Zero;
Contents.ForEach(reagent => TotalVolume += reagent.Quantity);
Volume = FixedPoint2.Zero;
foreach (var reagent in Contents)
{
Volume += reagent.Quantity;
}
if (MaxVolume == FixedPoint2.Zero)
MaxVolume = Volume;
}
public bool ContainsReagent(string reagentId)
{
return ContainsReagent(reagentId, out _);
foreach (var reagent in Contents)
{
if (reagent.ReagentId == reagentId)
return true;
}
return false;
}
public bool ContainsReagent(string reagentId, out FixedPoint2 quantity)
public bool TryGetReagent(string reagentId, out FixedPoint2 quantity)
{
foreach (var reagent in Contents)
{
@@ -82,15 +233,22 @@ namespace Content.Shared.Chemistry.Components
return false;
}
public string GetPrimaryReagentId()
public string? GetPrimaryReagentId()
{
if (Contents.Count == 0)
return null;
ReagentQuantity max = default;
foreach (var reagent in Contents)
{
return "";
if (reagent.Quantity >= max.Quantity)
{
max = reagent;
}
}
var majorReagent = Contents.MaxBy(reagent => reagent.Quantity);
return majorReagent.ReagentId;
return max.ReagentId!;
}
/// <summary>
@@ -98,16 +256,16 @@ namespace Content.Shared.Chemistry.Components
/// </summary>
/// <param name="reagentId">The prototype ID of the reagent to add.</param>
/// <param name="quantity">The quantity in milli-units.</param>
public void AddReagent(string reagentId, FixedPoint2 quantity, float? temperature = null)
public void AddReagent(string reagentId, FixedPoint2 quantity, bool dirtyHeatCap = true)
{
if (quantity <= 0)
{
DebugTools.Assert(quantity == 0, "Attempted to add negative reagent quantity");
return;
if (!IoCManager.Resolve<IPrototypeManager>().TryIndex(reagentId, out ReagentPrototype? proto))
proto = new ReagentPrototype();
}
var actualTemp = temperature ?? Temperature;
var oldThermalEnergy = Temperature * GetHeatCapacity();
var addedThermalEnergy = (float) quantity * proto.SpecificHeat * actualTemp;
Volume += quantity;
_heatCapacityDirty |= dirtyHeatCap;
for (var i = 0; i < Contents.Count; i++)
{
var reagent = Contents[i];
@@ -115,16 +273,65 @@ namespace Content.Shared.Chemistry.Components
continue;
Contents[i] = new ReagentQuantity(reagentId, reagent.Quantity + quantity);
TotalVolume += quantity;
ThermalEnergy = oldThermalEnergy + addedThermalEnergy;
ValidateSolution();
return;
}
Contents.Add(new ReagentQuantity(reagentId, quantity));
ValidateSolution();
}
TotalVolume += quantity;
ThermalEnergy = oldThermalEnergy + addedThermalEnergy;
/// <summary>
/// Adds a given quantity of a reagent directly into the solution.
/// </summary>
/// <param name="proto">The prototype of the reagent to add.</param>
/// <param name="quantity">The quantity in milli-units.</param>
public void AddReagent(ReagentPrototype proto, FixedPoint2 quantity)
{
AddReagent(proto.ID, quantity, false);
_heatCapacity += quantity.Float() * proto.SpecificHeat;
}
/// <summary>
/// Adds a given quantity of a reagent directly into the solution.
/// </summary>
/// <param name="proto">The prototype of the reagent to add.</param>
/// <param name="quantity">The quantity in milli-units.</param>
public void AddReagent(ReagentPrototype proto, FixedPoint2 quantity, float temperature, IPrototypeManager? protoMan)
{
if (_heatCapacityDirty)
UpdateHeatCapacity(protoMan);
var totalThermalEnergy = Temperature * _heatCapacity + temperature * proto.SpecificHeat;
AddReagent(proto, quantity);
Temperature = _heatCapacity == 0 ? 0 : totalThermalEnergy / _heatCapacity;
}
/// <summary>
/// Scales the amount of solution by some integer quantity.
/// </summary>
/// <param name="scale">The scalar to modify the solution by.</param>
public void ScaleSolution(int scale)
{
if (scale == 1)
return;
if (scale == 0)
{
RemoveAllSolution();
return;
}
_heatCapacity *= scale;
Volume *= scale;
for (int i = 0; i <= Contents.Count; i++)
{
var old = Contents[i];
Contents[i] = new ReagentQuantity(old.ReagentId, old.Quantity * scale);
}
ValidateSolution();
}
/// <summary>
@@ -133,21 +340,31 @@ namespace Content.Shared.Chemistry.Components
/// <param name="scale">The scalar to modify the solution by.</param>
public void ScaleSolution(float scale)
{
if (scale.Equals(1f))
if (scale == 1)
return;
var tempContents = new List<ReagentQuantity>(Contents);
foreach(var current in tempContents)
if (scale == 0)
{
if(scale > 1)
{
AddReagent(current.ReagentId, current.Quantity * scale - current.Quantity);
}
RemoveAllSolution();
return;
}
Volume = FixedPoint2.Zero;
for (int i = Contents.Count - 1; i >= 0; i--)
{
var old = Contents[i];
var newQuantity = old.Quantity * scale;
if (newQuantity == FixedPoint2.Zero)
Contents.RemoveSwap(i);
else
{
RemoveReagent(current.ReagentId, current.Quantity - current.Quantity * scale);
Contents[i] = new ReagentQuantity(old.ReagentId, newQuantity);
Volume += newQuantity;
}
}
_heatCapacityDirty = true;
ValidateSolution();
}
/// <summary>
@@ -159,11 +376,12 @@ namespace Content.Shared.Chemistry.Components
{
for (var i = 0; i < Contents.Count; i++)
{
if (Contents[i].ReagentId == reagentId)
return Contents[i].Quantity;
var reagent = Contents[i];
if (reagent.ReagentId == reagentId)
return reagent.Quantity;
}
return FixedPoint2.New(0);
return FixedPoint2.Zero;
}
/// <summary>
@@ -174,7 +392,7 @@ namespace Content.Shared.Chemistry.Components
/// <returns>How much reagent was actually removed. Zero if the reagent is not present on the solution.</returns>
public FixedPoint2 RemoveReagent(string reagentId, FixedPoint2 quantity)
{
if(quantity <= 0)
if (quantity <= FixedPoint2.Zero)
return FixedPoint2.Zero;
for (var i = 0; i < Contents.Count; i++)
@@ -186,16 +404,19 @@ namespace Content.Shared.Chemistry.Components
var curQuantity = reagent.Quantity;
var newQuantity = curQuantity - quantity;
_heatCapacityDirty = true;
if (newQuantity <= 0)
{
Contents.RemoveSwap(i);
TotalVolume -= curQuantity;
Volume -= curQuantity;
ValidateSolution();
return curQuantity;
}
Contents[i] = new ReagentQuantity(reagentId, newQuantity);
TotalVolume -= quantity;
Volume -= quantity;
ValidateSolution();
return quantity;
}
@@ -203,104 +424,150 @@ namespace Content.Shared.Chemistry.Components
return FixedPoint2.Zero;
}
/// <summary>
/// Remove the specified quantity from this solution.
/// </summary>
/// <param name="quantity">The quantity of this solution to remove</param>
public void RemoveSolution(FixedPoint2 quantity)
{
if(quantity <= 0)
return;
var ratio = (TotalVolume - quantity).Double() / TotalVolume.Double();
if (ratio <= 0)
{
RemoveAllSolution();
return;
}
for (var i = 0; i < Contents.Count; i++)
{
var reagent = Contents[i];
var oldQuantity = reagent.Quantity;
// quantity taken is always a little greedy, so fractional quantities get rounded up to the nearest
// whole unit. This should prevent little bits of chemical remaining because of float rounding errors.
var newQuantity = oldQuantity * ratio;
Contents[i] = new ReagentQuantity(reagent.ReagentId, newQuantity);
}
TotalVolume *= ratio;
}
public void RemoveAllSolution()
{
Contents.Clear();
TotalVolume = FixedPoint2.New(0);
Volume = FixedPoint2.Zero;
_heatCapacityDirty = false;
_heatCapacity = 0;
}
public Solution SplitSolution(FixedPoint2 quantity)
public Solution SplitSolution(FixedPoint2 toTake)
{
if (quantity <= 0)
if (toTake <= FixedPoint2.Zero)
return new Solution();
Solution newSolution;
if (quantity >= TotalVolume)
if (toTake >= Volume)
{
newSolution = Clone();
RemoveAllSolution();
return newSolution;
}
newSolution = new Solution();
var newTotalVolume = FixedPoint2.New(0);
var newHeatCapacity = 0.0d;
var remainingVolume = TotalVolume;
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
var origVol = Volume;
var effVol = Volume.Value;
newSolution = new Solution(Contents.Count) { Temperature = Temperature };
var remaining = (long) toTake.Value;
for (var i = Contents.Count - 1; i >= 0; i--)
for (var i = Contents.Count - 1; i >= 0; i--) // iterate backwards because of remove swap.
{
if (remainingVolume == FixedPoint2.Zero)
// shouldn't happen, but it can if someone, somehow has a reagent with 0-quantity in a solution.
break;
var reagent = Contents[i];
var ratio = (remainingVolume - quantity).Double() / remainingVolume.Double();
if(!prototypeManager.TryIndex(reagent.ReagentId, out ReagentPrototype? proto))
proto = new ReagentPrototype();
remainingVolume -= reagent.Quantity;
// This is set up such that integer rounding will tend to take more reagents.
var split = remaining * reagent.Quantity.Value / effVol;
var newQuantity = reagent.Quantity * ratio;
var splitQuantity = reagent.Quantity - newQuantity;
if (split <= 0)
{
effVol -= reagent.Quantity.Value;
DebugTools.Assert(split == 0, "Negative solution quantity while splitting? Long/int overflow?");
continue;
}
if (newQuantity > 0)
var splitQuantity = FixedPoint2.FromCents((int) split);
var newQuantity = reagent.Quantity - splitQuantity;
DebugTools.Assert(newQuantity >= 0);
if (newQuantity > FixedPoint2.Zero)
Contents[i] = new ReagentQuantity(reagent.ReagentId, newQuantity);
else
Contents.RemoveAt(i);
Contents.RemoveSwap(i);
if (splitQuantity > 0)
newSolution.Contents.Add(new ReagentQuantity(reagent.ReagentId, splitQuantity));
newTotalVolume += splitQuantity;
newHeatCapacity += (float) splitQuantity * proto.SpecificHeat;
quantity -= splitQuantity;
newSolution.Contents.Add(new ReagentQuantity(reagent.ReagentId, splitQuantity));
Volume -= splitQuantity;
remaining -= split;
effVol -= reagent.Quantity.Value;
}
newSolution.TotalVolume = newTotalVolume;
newSolution.Temperature = Temperature;
TotalVolume -= newTotalVolume;
newSolution.Volume = origVol - Volume;
DebugTools.Assert(remaining >= 0);
DebugTools.Assert(remaining == 0 || Volume == FixedPoint2.Zero);
_heatCapacityDirty = true;
newSolution._heatCapacityDirty = true;
ValidateSolution();
newSolution.ValidateSolution();
return newSolution;
}
public void AddSolution(Solution otherSolution)
/// <summary>
/// Variant of <see cref="SplitSolution(FixedPoint2)"/> that doesn't return a new solution containing the removed reagents.
/// </summary>
/// <param name="quantity">The quantity of this solution to remove</param>
public void RemoveSolution(FixedPoint2 toTake)
{
var oldThermalEnergy = Temperature * GetHeatCapacity();
var addedThermalEnergy = otherSolution.Temperature * otherSolution.GetHeatCapacity();
if (toTake <= FixedPoint2.Zero)
return;
if (toTake >= Volume)
{
RemoveAllSolution();
return;
}
var effVol = Volume.Value;
Volume -= toTake;
var remaining = (long) toTake.Value;
for (var i = Contents.Count - 1; i >= 0; i--)// iterate backwards because of remove swap.
{
var reagent = Contents[i];
// This is set up such that integer rounding will tend to take more reagents.
var split = remaining * reagent.Quantity.Value / effVol;
if (split <= 0)
{
effVol -= reagent.Quantity.Value;
DebugTools.Assert(split == 0, "Negative solution quantity while splitting? Long/int overflow?");
continue;
}
var splitQuantity = FixedPoint2.FromCents((int) split);
var newQuantity = reagent.Quantity - splitQuantity;
if (newQuantity > FixedPoint2.Zero)
Contents[i] = new ReagentQuantity(reagent.ReagentId, newQuantity);
else
Contents.RemoveSwap(i);
remaining -= split;
effVol -= reagent.Quantity.Value;
}
DebugTools.Assert(remaining >= 0);
DebugTools.Assert(remaining == 0 || Volume == FixedPoint2.Zero);
_heatCapacityDirty = true;
ValidateSolution();
}
public void AddSolution(Solution otherSolution, IPrototypeManager? protoMan)
{
if (otherSolution.Volume <= FixedPoint2.Zero)
return;
Volume += otherSolution.Volume;
var closeTemps = MathHelper.CloseTo(otherSolution.Temperature, Temperature);
float totalThermalEnergy = 0;
if (!closeTemps)
{
IoCManager.Resolve(ref protoMan);
if (_heatCapacityDirty)
UpdateHeatCapacity(protoMan);
if (otherSolution._heatCapacityDirty)
otherSolution.UpdateHeatCapacity(protoMan);
totalThermalEnergy = _heatCapacity * Temperature + otherSolution._heatCapacity * otherSolution.Temperature;
}
for (var i = 0; i < otherSolution.Contents.Count; i++)
{
var otherReagent = otherSolution.Contents[i];
@@ -323,65 +590,50 @@ namespace Content.Shared.Chemistry.Components
}
}
TotalVolume += otherSolution.TotalVolume;
ThermalEnergy = oldThermalEnergy + addedThermalEnergy;
_heatCapacity += otherSolution._heatCapacity;
if (closeTemps)
_heatCapacityDirty |= otherSolution._heatCapacityDirty;
else
Temperature = _heatCapacity == 0 ? 0 : totalThermalEnergy / _heatCapacity;
ValidateSolution();
}
private Color GetColor()
public Color GetColor(IPrototypeManager? protoMan)
{
if (TotalVolume == 0)
if (Volume == FixedPoint2.Zero)
{
return Color.Transparent;
}
IoCManager.Resolve(ref protoMan);
Color mixColor = default;
var runningTotalQuantity = FixedPoint2.New(0);
var protoManager = IoCManager.Resolve<IPrototypeManager>();
bool first = true;
foreach (var reagent in Contents)
{
runningTotalQuantity += reagent.Quantity;
if (!protoManager.TryIndex(reagent.ReagentId, out ReagentPrototype? proto))
if (!protoMan.TryIndex(reagent.ReagentId, out ReagentPrototype? proto))
{
continue;
}
if (mixColor == default)
if (first)
{
first = false;
mixColor = proto.SubstanceColor;
continue;
}
var interpolateValue = (1 / runningTotalQuantity.Float()) * reagent.Quantity.Float();
var interpolateValue = reagent.Quantity.Float() / runningTotalQuantity.Float();
mixColor = Color.InterpolateBetween(mixColor, proto.SubstanceColor, interpolateValue);
}
return mixColor;
}
public Solution Clone()
{
var volume = FixedPoint2.New(0);
var heatCapacity = 0.0d;
var newSolution = new Solution();
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
for (var i = 0; i < Contents.Count; i++)
{
var reagent = Contents[i];
if (!prototypeManager.TryIndex(reagent.ReagentId, out ReagentPrototype? proto))
proto = new ReagentPrototype();
newSolution.Contents.Add(reagent);
volume += reagent.Quantity;
heatCapacity += (float) reagent.Quantity * proto.SpecificHeat;
}
newSolution.TotalVolume = volume;
newSolution.Temperature = Temperature;
return newSolution;
}
[Obsolete("Use ReactiveSystem.DoEntityReaction")]
public void DoEntityReaction(EntityUid uid, ReactionMethod method)
{
@@ -429,7 +681,23 @@ namespace Content.Shared.Chemistry.Components
{
return GetEnumerator();
}
#endregion
public void SetContents(IEnumerable<ReagentQuantity> reagents, bool setMaxVol = false)
{
RemoveAllSolution();
_heatCapacityDirty = true;
Contents = new(reagents);
foreach (var reagent in Contents)
{
Volume += reagent.Quantity;
}
if (setMaxVol)
MaxVolume = Volume;
ValidateSolution();
}
}
}