Skip to content

Conversation

@Khundiann
Copy link

Summary

This PR adds support for creating and registering AdditiveDefinition at runtime via S1API, without mods needing brittle reflection/Harmony patterns.

Design choice: builder-only. After registration, the AdditiveDefinition wrapper exposes additive-specific properties as read-only to avoid mid-session mutation issues on globally-registered ScriptableObject definitions.

What’s included

  • New additive APIs:
    • S1API.Items.AdditiveDefinition (wrapper, read-only additive properties)
    • S1API.Items.AdditiveDefinitionBuilder (fluent builder; validates required state; registers via Registry.AddToRegistry)
    • S1API.Items.AdditiveItemCreator (CreateBuilder() + CloneFrom(...), throws on missing/wrong type)
  • Cross-runtime helper:
    • S1API.Internal.Utils.AutoPropertySetter to set serialized auto-properties that may be private set on Mono (property setter → "<PropName>k__BackingField" fallback)
  • Wrapping support:
    • ItemManager.GetItemDefinition(...) now returns AdditiveDefinition when the native type is AdditiveDefinition (checked before the generic storable wrapper).
  • Docs:
    • Added “Creating Runtime Additives” section to S1API/docs/items.md (neutral example + GameLifecycle.OnPreLoad recommendation).

Usage

Register additives during GameLifecycle.OnPreLoad:

var additive = AdditiveItemCreator.CreateBuilder()
    .WithBasicInfo("mymod_growth_booster", "Growth Booster", "A custom growth enhancer additive.", ItemCategory.Growing)
    .WithEffects(1.5f, 0.5f, 1.0f)
    .Build();

Testing

Find the smoke-test console command source file in the first comment under this PR!

  • Added a DEV-local console command for smoke testing:
    • s1api_dev_additive_smoke_test (runs immediately)
    • s1api_dev_additive_smoke_test arm (runs once on next GameLifecycle.OnPreLoad)
  • Validates:
    • Expected throws (CloneFrom(missing), Build() without WithBasicInfo, CloneFrom(non-additive))
    • Create/register/fetch flow and correct wrapper type
    • Effect values round-trip correctly

Notes

  • Builder-only avoids timing/caching/MP desync pitfalls of allowing runtime mutation of globally-shared ScriptableObject definitions.
  • If a future use-case requires live mutation, it should be introduced as an explicit opt-in “unsafe” API with clear docs.

@Khundiann
Copy link
Author

RuntimeAdditiveSmokeTestCommand.cs

using System;
using System.Collections.Generic;
using S1API.Console;
using S1API.Items;
using S1API.Lifecycle;
using S1API.Logging;
using CrossTypeUtils = global::S1API.Internal.Utils.CrossType;

#if (IL2CPPMELON)
using S1ItemFramework = Il2CppScheduleOne.ItemFramework;
using S1Registry = Il2CppScheduleOne.Registry;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
using S1ItemFramework = ScheduleOne.ItemFramework;
using S1Registry = ScheduleOne.Registry;
#endif

namespace S1API.Internal.DevLocal
{
    /// <summary>
    /// DEV-LOCAL: Console command to smoke-test runtime additive support.
    /// This file is intended to stay out of PR diffs.
    /// </summary>
    internal sealed class RuntimeAdditiveSmokeTestCommand : BaseConsoleCommand
    {
        private static readonly Log Logger = new Log("S1API.DevAdditiveSmokeTest");
        private static bool _armed;

        public override string CommandWord => "s1api_dev_additive_smoke_test";
        public override string CommandDescription => "Runs a smoke test for runtime additive creation/wrapping. Use 'arm' to run on next GameLifecycle.OnPreLoad.";
        public override string ExampleUsage => "s1api_dev_additive_smoke_test [arm]";

        public override void ExecuteCommand(List<string> args)
        {
            var mode = args != null && args.Count > 0 ? (args[0] ?? string.Empty).Trim().ToLowerInvariant() : string.Empty;
            if (mode == "arm")
            {
                Arm();
                return;
            }

            RunSmokeTest("Manual");
        }

        private static void Arm()
        {
            if (_armed)
            {
                Logger.Msg("[AdditiveSmokeTest] Already armed. Trigger a load to run on GameLifecycle.OnPreLoad.");
                return;
            }

            _armed = true;
            GameLifecycle.OnPreLoad += OnPreLoad;
            Logger.Msg("[AdditiveSmokeTest] Armed. Smoke test will run on next GameLifecycle.OnPreLoad.");
        }

        private static void OnPreLoad()
        {
            try
            {
                RunSmokeTest("OnPreLoad");
            }
            finally
            {
                _armed = false;
                GameLifecycle.OnPreLoad -= OnPreLoad;
            }
        }

        private static void RunSmokeTest(string tag)
        {
            Logger.Msg($"[AdditiveSmokeTest] START ({tag})");

            try
            {
                NegativeChecks();
                PositiveChecks();
                Logger.Msg($"[AdditiveSmokeTest] PASS ({tag})");
            }
            catch (Exception ex)
            {
                Logger.Error($"[AdditiveSmokeTest] FAIL ({tag}): {ex.Message}\n{ex.StackTrace}");
            }
        }

        private static void NegativeChecks()
        {
            // 1) CloneFrom should throw for missing IDs
            try
            {
                AdditiveItemCreator.CloneFrom("__s1api_dev_missing__");
                throw new Exception("Expected AdditiveItemCreator.CloneFrom(missing) to throw, but it did not.");
            }
            catch
            {
                Logger.Msg("[AdditiveSmokeTest] OK: CloneFrom(missing) threw.");
            }

            // 2) Build should throw if WithBasicInfo was never called
            try
            {
                AdditiveItemCreator.CreateBuilder().Build();
                throw new Exception("Expected AdditiveDefinitionBuilder.Build() to throw when WithBasicInfo was not called, but it did not.");
            }
            catch
            {
                Logger.Msg("[AdditiveSmokeTest] OK: Build() without WithBasicInfo threw.");
            }

            // 3) CloneFrom should throw for wrong type
            var nonAdditiveId = TryFindNonAdditiveId();
            if (!string.IsNullOrWhiteSpace(nonAdditiveId))
            {
                try
                {
                    AdditiveItemCreator.CloneFrom(nonAdditiveId);
                    throw new Exception($"Expected AdditiveItemCreator.CloneFrom('{nonAdditiveId}') to throw for non-additive type, but it did not.");
                }
                catch
                {
                    Logger.Msg($"[AdditiveSmokeTest] OK: CloneFrom(non-additive '{nonAdditiveId}') threw.");
                }
            }
            else
            {
                Logger.Warning("[AdditiveSmokeTest] SKIP: Could not find a stable non-additive ID to test CloneFrom(wrong type).");
            }
        }

        private static void PositiveChecks()
        {
            var suffix = DateTime.UtcNow.Ticks.ToString("x");
            var id = $"s1api_dev_test_additive_{suffix}";

            const float expectedYield = 1.5f;
            const float expectedInstant = 0.5f;
            const float expectedQuality = 1.0f;

            var created = AdditiveItemCreator.CreateBuilder()
                .WithBasicInfo(
                    id: id,
                    name: $"S1API Dev Additive {suffix}",
                    description: "DEV-LOCAL additive smoke test item.",
                    category: ItemCategory.Growing
                )
                .WithStackLimit(10)
                .WithPricing(basePurchasePrice: 1f, resellMultiplier: 0.5f)
                .WithEffects(expectedYield, expectedInstant, expectedQuality)
                .Build();

            if (created == null)
                throw new Exception("Build() returned null.");

            var fetched = ItemManager.GetItemDefinition(id);
            if (fetched == null)
                throw new Exception($"ItemManager.GetItemDefinition('{id}') returned null.");

            if (fetched is not AdditiveDefinition additive)
                throw new Exception($"ItemManager returned '{fetched.GetType().FullName}', expected '{typeof(AdditiveDefinition).FullName}'.");

            Logger.Msg($"[AdditiveSmokeTest] Created+Fetched: {additive.Name} ({additive.ID})");
            Logger.Msg($"[AdditiveSmokeTest] Effects: yield={additive.YieldMultiplier} instant={additive.InstantGrowth} quality={additive.QualityChange}");

            if (!Nearly(additive.YieldMultiplier, expectedYield)
                || !Nearly(additive.InstantGrowth, expectedInstant)
                || !Nearly(additive.QualityChange, expectedQuality))
            {
                throw new Exception(
                    $"Effect mismatch. Expected yield={expectedYield}, instant={expectedInstant}, quality={expectedQuality} " +
                    $"but got yield={additive.YieldMultiplier}, instant={additive.InstantGrowth}, quality={additive.QualityChange}.");
            }

            // Ensure native type is actually AdditiveDefinition (not just wrapped as storable)
            if (!CrossTypeUtils.Is(created.S1ItemDefinition, out S1ItemFramework.AdditiveDefinition _))
            {
                throw new Exception("Created additive wrapper does not wrap a native AdditiveDefinition.");
            }
        }

        private static bool Nearly(float a, float b)
        {
            return Math.Abs(a - b) < 0.0001f;
        }

        private static string? TryFindNonAdditiveId()
        {
            try
            {
                var registry = S1Registry.Instance;
                if (registry == null)
                    return null;

                var items = registry.GetAllItems();
                if (items == null)
                    return null;

                foreach (var item in items)
                {
                    if (item == null)
                        continue;

                    // Find a storable item that is not an additive
                    if (CrossTypeUtils.Is(item, out S1ItemFramework.AdditiveDefinition _))
                        continue;

                    var id = item.ID;
                    if (string.IsNullOrWhiteSpace(id))
                        continue;

                    return id;
                }
            }
            catch
            {
                // ignored; best effort only
            }

            return null;
        }
    }
}

@ifBars ifBars added the enhancement New feature or request label Jan 27, 2026
@ifBars ifBars self-assigned this Jan 27, 2026
Copy link
Owner

@ifBars ifBars left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks nice, thanks for the PR :)

@ifBars ifBars merged commit 6b482b3 into ifBars:stable Jan 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants