forked from KaBooMa/S1API
-
-
Notifications
You must be signed in to change notification settings - Fork 17
items: add AdditiveDefinitionBuilder + AdditiveItemCreator #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ifBars
merged 1 commit into
ifBars:stable
from
Khundiann:items/add-AdditiveDefinitionBuilder-+-AdditiveItemCreator
Jan 27, 2026
Merged
items: add AdditiveDefinitionBuilder + AdditiveItemCreator #39
ifBars
merged 1 commit into
ifBars:stable
from
Khundiann:items/add-AdditiveDefinitionBuilder-+-AdditiveItemCreator
Jan 27, 2026
Conversation
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
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
approved these changes
Jan 27, 2026
Owner
ifBars
left a comment
There was a problem hiding this 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 :)
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(runs immediately)s1api_dev_additive_smoke_test arm(runs once on nextGameLifecycle.OnPreLoad)CloneFrom(missing),Build()withoutWithBasicInfo,CloneFrom(non-additive))Notes
ScriptableObjectdefinitions.