Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions S1API.Tests/Products/ProductMixingOutputTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using S1API.Products;

namespace S1API.Tests.Products;

public sealed class ProductMixingOutputTests
{
[Fact]
public void LegacyInputKeepsScalarMembersAndExposesNoEffects()
{
ProductKind kind = CreateKind();

var input = new ProductMixingOutput("example:output", "Named Mix", "example:source", kind, 20f);

Assert.Equal("example:output", input.OutputId);
Assert.Equal("Named Mix", input.MixName);
Assert.Equal("example:source", input.SourceProductId);
Assert.Same(kind, input.SourceKind);
Assert.Equal(20f, input.SourcePrice);
Assert.Empty(input.Properties);
Assert.Throws<InvalidOperationException>(() => input.CalculateNativeValue(20f));
}

[Fact]
public void PropertiesPreserveNativeOrderAndValues()
{
var calming = new ProductMixingEffect("calming", "Calming", 1, 0f, 1.1f);
var toxic = new ProductMixingEffect("toxic", "Toxic", 3, -4f, 1f);

ProductMixingOutput input = CreateInput(new[] { calming, toxic }, null);

Assert.Equal(new[] { "calming", "toxic" }, input.Properties.Select(effect => effect.Id));
Assert.Same(calming, input.Properties[0]);
Assert.Equal("Toxic", input.Properties[1].Name);
Assert.Equal(3, input.Properties[1].Tier);
Assert.Equal(-4f, input.Properties[1].ValueChange);
Assert.Equal(1.1f, input.Properties[0].ValueMultiplier);
}

[Fact]
public void PropertiesAreAReadOnlySnapshot()
{
var source = new List<ProductMixingEffect>
{
new ProductMixingEffect("calming", "Calming", 1, 0f, 1.1f)
};

ProductMixingOutput input = CreateInput(source, null);
source.Add(new ProductMixingEffect("toxic", "Toxic", 3, -4f, 1f));

Assert.Single(input.Properties);
var asList = Assert.IsAssignableFrom<IList<ProductMixingEffect>>(input.Properties);
Assert.True(asList.IsReadOnly);
Assert.Throws<NotSupportedException>(() => asList.Add(source[1]));
}

[Fact]
public void EffectSnapshotRejectsNullIdAndNormalizesNullName()
{
Assert.Throws<ArgumentNullException>(() => new ProductMixingEffect(null!, "Name", 1, 0f, 1f));
Assert.Equal(string.Empty, new ProductMixingEffect("id", null, 1, 0f, 1f).Name);
}

[Fact]
public void NullPropertiesAreRejected()
{
Assert.Throws<ArgumentNullException>(() => CreateInput(null!, null));
}

[Fact]
public void CalculateNativeValueForwardsTheBaseValueToTheNativeCalculation()
{
var received = new List<float>();
ProductMixingOutput input = CreateInput(
Array.Empty<ProductMixingEffect>(),
baseValue =>
{
received.Add(baseValue);
return baseValue * 2f;
});

Assert.Equal(70f, input.CalculateNativeValue(35f));
Assert.Equal(new[] { 35f }, received);
}

[Theory]
[InlineData(float.NaN)]
[InlineData(float.PositiveInfinity)]
[InlineData(float.NegativeInfinity)]
public void CalculateNativeValueRejectsNonFiniteBaseValues(float baseValue)
{
var called = false;
ProductMixingOutput input = CreateInput(
Array.Empty<ProductMixingEffect>(),
value =>
{
called = true;
return value;
});

Assert.Throws<ArgumentOutOfRangeException>(() => input.CalculateNativeValue(baseValue));
Assert.False(called);
}

[Fact]
public void FactoryCanPriceOutputFromNativeEffectValue()
{
ProductKind kind = CreateKind();
ProductMixingProfile profile = new ProductMixingProfileBuilder(kind)
.WithMixerMap(ProductMixingMap.Cocaine)
.WithOutputFactory(input => new ProductMixingOutputDefinition(
input.MixName,
input.SourceKind,
input.CalculateNativeValue(40f)))
.Build();

ProductMixingOutputDefinition output = profile.OutputFactory(new ProductMixingOutput(
"example:output",
"Named Mix",
"example:source",
kind,
50f,
new[] { new ProductMixingEffect("calming", "Calming", 1, 0f, 1.5f) },
baseValue => baseValue * 1.5f));

Assert.Equal(60f, output.Price);
}

[Fact]
public void ExistingPublicSurfaceRemainsAvailable()
{
Type type = typeof(ProductMixingOutput);

Assert.True(type.IsSealed);
Assert.Empty(type.GetConstructors());
Assert.Equal(typeof(string), type.GetProperty(nameof(ProductMixingOutput.OutputId))!.PropertyType);
Assert.Equal(typeof(string), type.GetProperty(nameof(ProductMixingOutput.MixName))!.PropertyType);
Assert.Equal(typeof(string), type.GetProperty(nameof(ProductMixingOutput.SourceProductId))!.PropertyType);
Assert.Equal(typeof(ProductKind), type.GetProperty(nameof(ProductMixingOutput.SourceKind))!.PropertyType);
Assert.Equal(typeof(float), type.GetProperty(nameof(ProductMixingOutput.SourcePrice))!.PropertyType);
Assert.Empty(typeof(ProductMixingEffect).GetConstructors());
}

private static ProductMixingOutput CreateInput(
IReadOnlyList<ProductMixingEffect> properties,
Func<float, float>? nativeValueCalculator)
{
return new ProductMixingOutput(
"example:output",
"Named Mix",
"example:source",
CreateKind(),
20f,
properties,
nativeValueCalculator);
}

private static ProductKind CreateKind()
{
return new ProductKindBuilder("mixingoutputtests:" + Guid.NewGuid().ToString("N"))
.WithCompatibilityDrugType(DrugType.Cocaine)
.Build();
}
}
38 changes: 37 additions & 1 deletion S1API/Internal/Patches/MixReactionPatches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,26 @@ private static bool FinishAndNameMix_Prefix(
new System.Collections.Generic.List<S1Effects.Effect>(properties.Count);
for (int i = 0; i < properties.Count; i++)
resolvedProperties.Add(properties[i]);
var effects = new System.Collections.Generic.List<ProductMixingEffect>(resolvedProperties.Count);
for (int i = 0; i < resolvedProperties.Count; i++)
{
S1Effects.Effect property = resolvedProperties[i];
effects.Add(new ProductMixingEffect(
property.ID,
property.Name,
(int)property.Tier,
property.ValueChange,
property.ValueMultiplier));
}

var outputInput = new ProductMixingOutput(
mixID,
mixName,
productID,
metadata.ProductKind,
source.BasePrice);
source.BasePrice,
effects,
baseValue => CalculateNativeValue(resolvedProperties, baseValue));
ProductMixingOutputDefinition output = profile!.OutputFactory(outputInput)
?? throw new InvalidOperationException("The mixing output factory returned null.");
DrugType nativeDrugType = ProductMixingMapContract.GetNativeDrugType(profile.MixerMap);
Expand Down Expand Up @@ -337,6 +351,28 @@ private static void CompleteGeneratedMixCreation(
autoList: false);
productManager.onNewProductCreated?.Invoke(generated);
}

private static float CalculateNativeValue(
System.Collections.Generic.List<S1Effects.Effect> properties,
float baseValue)
{
// The native calculation reads a definition's properties, so price a short-lived
// probe rather than copying the formula and drifting from the game.
S1Product.ProductDefinition probe =
UnityEngine.ScriptableObject.CreateInstance<S1Product.ProductDefinition>();
try
{
probe.Properties = new EffectList();
for (int i = 0; i < properties.Count; i++)
probe.Properties.Add(properties[i]);

return S1Product.ProductManager.CalculateProductValue(probe, baseValue);
}
finally
{
CustomProductDefinitionFactory.Destroy(probe);
}
}
}

internal static class CustomProductMixingIngredientContract
Expand Down
42 changes: 42 additions & 0 deletions S1API/Products/ProductMixingEffect.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;

namespace S1API.Products
{
/// <summary>
/// Describes one effect on a generated mix output, as resolved by the native mixing calculation.
/// </summary>
/// <remarks>
/// This is a runtime-independent snapshot. It does not reference the native effect, so it is
/// safe to inspect from a deterministic mixing-output factory on Mono and IL2CPP alike.
/// </remarks>
public sealed class ProductMixingEffect
{
/// <summary>
/// INTERNAL: Creates an effect snapshot.
/// </summary>
/// <param name="id">The native effect ID.</param>
/// <param name="name">The effect display name.</param>
/// <param name="tier">The native effect tier.</param>
/// <param name="valueChange">The flat value the effect adds to a product's base value.</param>
/// <param name="valueMultiplier">The multiplier the effect contributes to a product's value.</param>
internal ProductMixingEffect(string id, string? name, int tier, float valueChange, float valueMultiplier)
{
Id = id ?? throw new ArgumentNullException(nameof(id));
Name = name ?? string.Empty;
Tier = tier;
ValueChange = valueChange;
ValueMultiplier = valueMultiplier;
}

/// <summary>Gets the native effect ID.</summary>
public string Id { get; }
/// <summary>Gets the effect display name.</summary>
public string Name { get; }
/// <summary>Gets the native effect tier.</summary>
public int Tier { get; }
/// <summary>Gets the flat value the effect adds to a product's base value.</summary>
public float ValueChange { get; }
/// <summary>Gets the multiplier the effect contributes to a product's value.</summary>
public float ValueMultiplier { get; }
}
}
61 changes: 61 additions & 0 deletions S1API/Products/ProductMixingOutput.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;

namespace S1API.Products
{
Expand All @@ -7,13 +9,37 @@ namespace S1API.Products
/// </summary>
public sealed class ProductMixingOutput
{
private static readonly IReadOnlyList<ProductMixingEffect> NoProperties =
new ReadOnlyCollection<ProductMixingEffect>(new ProductMixingEffect[0]);

private readonly Func<float, float>? _nativeValueCalculator;

internal ProductMixingOutput(string outputId, string mixName, string sourceProductId, ProductKind sourceKind, float sourcePrice)
: this(outputId, mixName, sourceProductId, sourceKind, sourcePrice, NoProperties, null)
{
}

internal ProductMixingOutput(
string outputId,
string mixName,
string sourceProductId,
ProductKind sourceKind,
float sourcePrice,
IReadOnlyList<ProductMixingEffect> properties,
Func<float, float>? nativeValueCalculator)
{
if (properties == null)
throw new ArgumentNullException(nameof(properties));

OutputId = outputId;
MixName = mixName;
SourceProductId = sourceProductId;
SourceKind = sourceKind;
SourcePrice = sourcePrice;
Properties = properties.Count == 0
? NoProperties
: new ReadOnlyCollection<ProductMixingEffect>(new List<ProductMixingEffect>(properties));
_nativeValueCalculator = nativeValueCalculator;
}

/// <summary>Gets the stable ID allocated by the native mixing flow.</summary>
Expand All @@ -25,6 +51,41 @@ internal ProductMixingOutput(string outputId, string mixName, string sourceProdu
/// <summary>Gets the source logical product kind.</summary>
public ProductKind SourceKind { get; }
/// <summary>Gets the source product's current base price.</summary>
/// <remarks>
/// For a source that is itself a generated mix, this is the price its output factory
/// returned, so adding to it compounds across repeated mixes.
/// </remarks>
public float SourcePrice { get; }

/// <summary>
/// Gets the effects the generated output will carry, in native order, after the native
/// mixing calculation and any registered mixing reactions.
/// </summary>
/// <remarks>Empty when the input was not produced by the native mixing flow.</remarks>
public IReadOnlyList<ProductMixingEffect> Properties { get; }

/// <summary>
/// Calculates a value for the output's <see cref="Properties"/> using the native
/// product-value calculation, the same one that prices native mixes.
/// </summary>
/// <remarks>
/// Native mixes are priced from their product family's fixed base price, not from the
/// source product's price. Pass your kind's fixed base price to match that behavior.
/// The result is not clamped; <see cref="ProductMixingOutputDefinition"/> accepts prices
/// from 1 through 999.
/// </remarks>
/// <param name="baseValue">The finite base value the effects are applied to.</param>
/// <returns>The native value of a product with these effects and base value.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="baseValue"/> is NaN or infinite.</exception>
/// <exception cref="InvalidOperationException">Thrown when the input was not produced by the native mixing flow.</exception>
public float CalculateNativeValue(float baseValue)
{
if (float.IsNaN(baseValue) || float.IsInfinity(baseValue))
throw new ArgumentOutOfRangeException(nameof(baseValue), "Base value must be finite.");
if (_nativeValueCalculator == null)
throw new InvalidOperationException("Native value calculation is only available during the native mixing flow.");

return _nativeValueCalculator(baseValue);
}
}
}
25 changes: 24 additions & 1 deletion S1API/docs/mixing-ingredients.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,39 @@ rejects the mix before the unsupported native switch. Do not cast or invent
native enum values.

```csharp
const float MdmaBasePrice = 40f;
var kind = new ProductKindBuilder("moredrugs:mdma").Build();

var profile = new ProductMixingProfileBuilder(kind)
.WithMixerMap(ProductMixingMap.Cocaine)
.WithOutputFactoryCompatibility("moredrugs:mdma-mixing", 1)
.WithOutputFactory(input => new ProductMixingOutputDefinition(
input.MixName, input.SourceKind, input.SourcePrice + 10f))
input.MixName,
input.SourceKind,
Math.Min(999f, Math.Max(1f, input.CalculateNativeValue(MdmaBasePrice)))))
.Build();
```

### Pricing a mix from its effects

`ProductMixingOutput.Properties` lists the effects the output will carry, in
native order, after the native mixing calculation and any registered mixing
reactions. Each `ProductMixingEffect` exposes its `Id`, `Name`, `Tier`,
`ValueChange`, and `ValueMultiplier`.

`ProductMixingOutput.CalculateNativeValue(baseValue)` prices those effects with
the game's own product-value calculation, so mods do not need a table of effect
multipliers. Native mixes are priced from their family's fixed base price, so
pass your kind's fixed base price rather than `SourcePrice`. `SourcePrice` is
the source product's price; for a source that is itself a generated mix it is
the price that mix's factory returned, so adding to it compounds across
repeated mixes. Clamp the result to the 1 through 999 range that
`ProductMixingOutputDefinition` accepts.

The factory runs on every peer, and both members depend only on the mix's
effects, so the result stays deterministic. When you change how a factory
prices outputs, bump its `WithOutputFactoryCompatibility` version.

Use the same output-factory compatibility identity and version on every peer.
S1API includes it and the selected map in the multiplayer manifest, rejects a
mismatch before player data, and transports scalar descriptors for mixes created
Expand Down