diff --git a/S1API.Tests/Products/ProductMixingOutputTests.cs b/S1API.Tests/Products/ProductMixingOutputTests.cs new file mode 100644 index 00000000..7406093a --- /dev/null +++ b/S1API.Tests/Products/ProductMixingOutputTests.cs @@ -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(() => 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 + { + 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>(input.Properties); + Assert.True(asList.IsReadOnly); + Assert.Throws(() => asList.Add(source[1])); + } + + [Fact] + public void EffectSnapshotRejectsNullIdAndNormalizesNullName() + { + Assert.Throws(() => 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(() => CreateInput(null!, null)); + } + + [Fact] + public void CalculateNativeValueForwardsTheBaseValueToTheNativeCalculation() + { + var received = new List(); + ProductMixingOutput input = CreateInput( + Array.Empty(), + 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(), + value => + { + called = true; + return value; + }); + + Assert.Throws(() => 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 properties, + Func? 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(); + } +} diff --git a/S1API/Internal/Patches/MixReactionPatches.cs b/S1API/Internal/Patches/MixReactionPatches.cs index c88fccfd..4d6b32e1 100644 --- a/S1API/Internal/Patches/MixReactionPatches.cs +++ b/S1API/Internal/Patches/MixReactionPatches.cs @@ -202,12 +202,26 @@ private static bool FinishAndNameMix_Prefix( new System.Collections.Generic.List(properties.Count); for (int i = 0; i < properties.Count; i++) resolvedProperties.Add(properties[i]); + var effects = new System.Collections.Generic.List(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); @@ -337,6 +351,28 @@ private static void CompleteGeneratedMixCreation( autoList: false); productManager.onNewProductCreated?.Invoke(generated); } + + private static float CalculateNativeValue( + System.Collections.Generic.List 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(); + 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 diff --git a/S1API/Products/ProductMixingEffect.cs b/S1API/Products/ProductMixingEffect.cs new file mode 100644 index 00000000..79ce6e15 --- /dev/null +++ b/S1API/Products/ProductMixingEffect.cs @@ -0,0 +1,42 @@ +using System; + +namespace S1API.Products +{ + /// + /// Describes one effect on a generated mix output, as resolved by the native mixing calculation. + /// + /// + /// 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. + /// + public sealed class ProductMixingEffect + { + /// + /// INTERNAL: Creates an effect snapshot. + /// + /// The native effect ID. + /// The effect display name. + /// The native effect tier. + /// The flat value the effect adds to a product's base value. + /// The multiplier the effect contributes to a product's value. + 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; + } + + /// Gets the native effect ID. + public string Id { get; } + /// Gets the effect display name. + public string Name { get; } + /// Gets the native effect tier. + public int Tier { get; } + /// Gets the flat value the effect adds to a product's base value. + public float ValueChange { get; } + /// Gets the multiplier the effect contributes to a product's value. + public float ValueMultiplier { get; } + } +} diff --git a/S1API/Products/ProductMixingOutput.cs b/S1API/Products/ProductMixingOutput.cs index 7e83581c..48f60439 100644 --- a/S1API/Products/ProductMixingOutput.cs +++ b/S1API/Products/ProductMixingOutput.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; namespace S1API.Products { @@ -7,13 +9,37 @@ namespace S1API.Products /// public sealed class ProductMixingOutput { + private static readonly IReadOnlyList NoProperties = + new ReadOnlyCollection(new ProductMixingEffect[0]); + + private readonly Func? _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 properties, + Func? 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(new List(properties)); + _nativeValueCalculator = nativeValueCalculator; } /// Gets the stable ID allocated by the native mixing flow. @@ -25,6 +51,41 @@ internal ProductMixingOutput(string outputId, string mixName, string sourceProdu /// Gets the source logical product kind. public ProductKind SourceKind { get; } /// Gets the source product's current base price. + /// + /// 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. + /// public float SourcePrice { get; } + + /// + /// Gets the effects the generated output will carry, in native order, after the native + /// mixing calculation and any registered mixing reactions. + /// + /// Empty when the input was not produced by the native mixing flow. + public IReadOnlyList Properties { get; } + + /// + /// Calculates a value for the output's using the native + /// product-value calculation, the same one that prices native mixes. + /// + /// + /// 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; accepts prices + /// from 1 through 999. + /// + /// The finite base value the effects are applied to. + /// The native value of a product with these effects and base value. + /// Thrown when is NaN or infinite. + /// Thrown when the input was not produced by the native mixing flow. + 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); + } } } diff --git a/S1API/docs/mixing-ingredients.md b/S1API/docs/mixing-ingredients.md index 28aaa062..44a903d9 100644 --- a/S1API/docs/mixing-ingredients.md +++ b/S1API/docs/mixing-ingredients.md @@ -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