items: add AdditiveDefinitionBuilder + AdditiveItemCreator#39
Merged
ifBars merged 1 commit intoifBars:stablefrom Jan 27, 2026
Conversation
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;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
using S1ItemFramework = ScheduleOne.ItemFramework;
#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;
private static string? _armedNonAdditiveItemId;
public override string CommandWord => "s1api_dev_additive_smoke_test";
public override string CommandDescription => "Runs a smoke test for runtime additive creation/wrapping. Provide a known non-additive item ID (e.g. 'cash'). Use 'arm' to run on next GameLifecycle.OnPreLoad.";
public override string ExampleUsage => "s1api_dev_additive_smoke_test [arm] <nonAdditiveItemId>";
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")
{
var nonAdditiveItemId = args != null && args.Count > 1 ? (args[1] ?? string.Empty).Trim() : string.Empty;
if (string.IsNullOrWhiteSpace(nonAdditiveItemId))
{
Logger.Error("[AdditiveSmokeTest] FAIL: Missing <nonAdditiveItemId>. Example: s1api_dev_additive_smoke_test arm cash");
return;
}
_armedNonAdditiveItemId = nonAdditiveItemId;
Arm();
return;
}
var manualNonAdditiveItemId = args != null && args.Count > 0 ? (args[0] ?? string.Empty).Trim() : string.Empty;
if (string.IsNullOrWhiteSpace(manualNonAdditiveItemId))
{
Logger.Error("[AdditiveSmokeTest] FAIL: Missing <nonAdditiveItemId>. Example: s1api_dev_additive_smoke_test cash");
return;
}
RunSmokeTest("Manual", manualNonAdditiveItemId);
}
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
{
var nonAdditiveItemId = _armedNonAdditiveItemId;
if (string.IsNullOrWhiteSpace(nonAdditiveItemId))
{
Logger.Error("[AdditiveSmokeTest] FAIL (OnPreLoad): Missing <nonAdditiveItemId>. Re-arm with: s1api_dev_additive_smoke_test arm <nonAdditiveItemId>");
return;
}
RunSmokeTest("OnPreLoad", nonAdditiveItemId);
}
finally
{
_armed = false;
_armedNonAdditiveItemId = null;
GameLifecycle.OnPreLoad -= OnPreLoad;
}
}
private static void RunSmokeTest(string tag, string nonAdditiveItemId)
{
Logger.Msg($"[AdditiveSmokeTest] START ({tag})");
try
{
NegativeChecks(nonAdditiveItemId);
PositiveChecks();
Logger.Msg($"[AdditiveSmokeTest] PASS ({tag})");
}
catch (Exception ex)
{
Logger.Error($"[AdditiveSmokeTest] FAIL ({tag}): {ex.Message}\n{ex.StackTrace}");
}
}
private static void NegativeChecks(string nonAdditiveItemId)
{
// 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 nonAdditive = ItemManager.GetItemDefinition(nonAdditiveItemId);
if (nonAdditive == null)
{
throw new Exception($"Non-additive source item '{nonAdditiveItemId}' was not found.");
}
if (nonAdditive is AdditiveDefinition)
{
throw new Exception($"Non-additive source item '{nonAdditiveItemId}' resolved to an AdditiveDefinition; pick a non-additive item ID.");
}
if (CrossTypeUtils.Is(nonAdditive.S1ItemDefinition, out S1ItemFramework.AdditiveDefinition _))
{
throw new Exception($"Non-additive source item '{nonAdditiveItemId}' wraps a native AdditiveDefinition; pick a non-additive item ID.");
}
try
{
AdditiveItemCreator.CloneFrom(nonAdditiveItemId);
throw new Exception($"Expected AdditiveItemCreator.CloneFrom('{nonAdditiveItemId}') to throw for non-additive type, but it did not.");
}
catch
{
Logger.Msg($"[AdditiveSmokeTest] OK: CloneFrom(non-additive '{nonAdditiveItemId}') threw.");
}
}
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;
}
}
} |
ifBars
approved these changes
Jan 27, 2026
Owner
ifBars
left a comment
There was a problem hiding this comment.
Looks nice, thanks for the PR :)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds support for creating and registering
AdditiveDefinitionat runtime via S1API, without mods needing brittle reflection/Harmony patterns.Design choice: builder-only. After registration, the
AdditiveDefinitionwrapper exposes additive-specific properties as read-only to avoid mid-session mutation issues on globally-registeredScriptableObjectdefinitions.What’s included
S1API.Items.AdditiveDefinition(wrapper, read-only additive properties)S1API.Items.AdditiveDefinitionBuilder(fluent builder; validates required state; registers viaRegistry.AddToRegistry)S1API.Items.AdditiveItemCreator(CreateBuilder()+CloneFrom(...), throws on missing/wrong type)S1API.Internal.Utils.AutoPropertySetterto set serialized auto-properties that may beprivate seton Mono (property setter →"<PropName>k__BackingField"fallback)ItemManager.GetItemDefinition(...)now returnsAdditiveDefinitionwhen the native type isAdditiveDefinition(checked before the generic storable wrapper).S1API/docs/items.md(neutral example +GameLifecycle.OnPreLoadrecommendation).Usage
Register additives during
GameLifecycle.OnPreLoad:Testing
Find the smoke-test console command source file in the first comment under this PR!
s1api_dev_additive_smoke_test <nonAdditiveItemId>(runs immediately; example:cash)s1api_dev_additive_smoke_test arm <nonAdditiveItemId>(runs once on nextGameLifecycle.OnPreLoad)CloneFrom(missing),Build()withoutWithBasicInfo,CloneFrom(<nonAdditiveItemId>))Notes
ScriptableObjectdefinitions.