items: add StationItem support for station/minigame ingredients#42
Merged
ifBars merged 1 commit intoifBars:stablefrom Jan 29, 2026
Conversation
Author
|
RuntimeStationItemSmokeTestCommand.cs using System;
using System.Collections;
using System.Collections.Generic;
using MelonLoader;
using S1API.Console;
using S1API.Internal.Utils;
using S1API.Items;
using S1API.Logging;
using S1API.Stations;
using UnityEngine;
using UnityEngine.SceneManagement;
#if (IL2CPPMELON)
using S1ItemDefList = Il2CppSystem.Collections.Generic.List<Il2CppScheduleOne.ItemFramework.ItemDefinition>;
using S1ItemFramework = Il2CppScheduleOne.ItemFramework;
using S1Registry = Il2CppScheduleOne.Registry;
using S1StationFramework = Il2CppScheduleOne.StationFramework;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
using S1ItemDefList = System.Collections.Generic.List<ScheduleOne.ItemFramework.ItemDefinition>;
using S1ItemFramework = ScheduleOne.ItemFramework;
using S1Registry = ScheduleOne.Registry;
using S1StationFramework = ScheduleOne.StationFramework;
#endif
namespace S1API.Internal.DevLocal
{
/// <summary>
/// DEV-LOCAL: Console command to smoke-test StationItem assignment for runtime storable items.
/// This file is intended to stay out of PR diffs.
/// </summary>
internal sealed class RuntimeStationItemSmokeTestCommand : BaseConsoleCommand
{
private static readonly Log Logger = new Log("S1API.DevStationItemSmokeTest");
private static bool _armed;
private static bool _running;
public override string CommandWord => "s1api_dev_station_item_smoke_test";
public override string CommandDescription => "Runs a smoke test for assigning StationItem to runtime items. Requires a source base-game item ID (e.g., 'acid'). Use 'arm' to run once after Main scene loads.";
public override string ExampleUsage => "s1api_dev_station_item_smoke_test [arm] <sourceItemId>";
public override void ExecuteCommand(List<string> args)
{
var parsed = ParseArgs(args);
if (parsed == null)
{
Logger.Warning("[StationItemSmokeTest] Missing required <sourceItemId>. Example: s1api_dev_station_item_smoke_test acid");
Logger.Warning("[StationItemSmokeTest] To arm: s1api_dev_station_item_smoke_test arm acid");
return;
}
if (parsed.Value.Arm)
{
Arm(parsed.Value.SourceItemId);
return;
}
RunSmokeTest("Manual", parsed.Value.SourceItemId);
}
private static void Arm(string sourceItemId)
{
if (_armed)
{
Logger.Msg("[StationItemSmokeTest] Already armed. Trigger a scene load to run once after Main is loaded.");
return;
}
_armed = true;
if (_running)
return;
_running = true;
MelonCoroutines.Start(WaitForMainThenRun(sourceItemId));
Logger.Msg("[StationItemSmokeTest] Armed. Smoke test will run once after Main is active.");
}
private static IEnumerator WaitForMainThenRun(string sourceItemId)
{
try
{
_armed = false;
var start = Time.realtimeSinceStartup;
const float timeoutSeconds = 30f;
while (Time.realtimeSinceStartup - start < timeoutSeconds)
{
try
{
if (string.Equals(SceneManager.GetActiveScene().name, "Main", StringComparison.OrdinalIgnoreCase))
break;
}
catch
{
// ignore
}
yield return null;
}
// Give the scene a moment to initialize Registry and resources.
yield return null;
yield return null;
RunSmokeTest("Armed", sourceItemId);
}
finally
{
_running = false;
}
}
private static void RunSmokeTest(string tag, string sourceItemId)
{
Logger.Msg($"[StationItemSmokeTest] START ({tag})");
try
{
var stationItemPrefab = GetStationItemPrefabOrThrow(sourceItemId);
var suffix = DateTime.UtcNow.Ticks.ToString("x");
var itemId = $"s1api_dev_station_item_ingredient_{suffix}";
var ingredient = ItemCreator.CreateBuilder()
.WithBasicInfo(itemId, $"S1API Dev Station Ingredient {suffix}", "DEV-LOCAL ingredient for StationItem smoke test.", ItemCategory.Consumable)
.WithStationItem(stationItemPrefab)
.Build();
if (!ingredient.HasStationItem || ingredient.StationItemPrefab == null)
throw new Exception("Created item does not report a StationItem after WithStationItem(...).");
var productId = "cash";
var productQty = (int)(DateTime.UtcNow.Ticks % 9000) + 1000; // keeps RecipeID unique: "{qty}x{productId}"
ChemistryStationRecipes.CreateAndRegister(b => b
.WithTitle($"S1API Dev StationItem Recipe {suffix}")
.WithCookTimeMinutes(1)
.WithFinalLiquidColor(Color.white)
.WithProduct(productId, productQty)
.WithIngredient(itemId, 1)
);
Logger.Msg($"[StationItemSmokeTest] OK: Created runtime item with StationItem: {itemId}");
Logger.Msg($"[StationItemSmokeTest] OK: Registered chemistry recipe using that item (product={productQty}x{productId}).");
Logger.Msg($"[StationItemSmokeTest] PASS ({tag})");
}
catch (Exception ex)
{
Logger.Error($"[StationItemSmokeTest] FAIL ({tag}): {ex.Message}\n{ex.StackTrace}");
}
}
private static GameObject GetStationItemPrefabOrThrow(string sourceItemId)
{
var id = (sourceItemId ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Source item ID is required.", nameof(sourceItemId));
S1ItemFramework.ItemDefinition def;
try { def = S1Registry.GetItem(id); }
catch (Exception ex)
{
throw new InvalidOperationException(
"ScheduleOne.Registry is not available yet. Run this command after Main is loaded (or use 'arm').",
ex);
}
if (def == null)
throw new InvalidOperationException($"Source item '{id}' was not found in the registry.");
if (!CrossType.Is(def, out S1ItemFramework.StorableItemDefinition storable))
throw new InvalidOperationException($"Source item '{id}' is not a storable item.");
var stationItem = storable.StationItem;
if (stationItem == null)
throw new InvalidOperationException($"Source item '{id}' does not have a StationItem.");
var hasIngredientModule = stationItem.GetComponentInChildren<S1StationFramework.IngredientModule>(true) != null;
var hasPourableModule = stationItem.GetComponentInChildren<S1StationFramework.PourableModule>(true) != null;
if (!hasIngredientModule && !hasPourableModule)
throw new InvalidOperationException($"Source item '{id}' StationItem does not contain an IngredientModule or PourableModule.");
return stationItem.gameObject;
}
private static ParsedArgs? ParseArgs(List<string> args)
{
if (args == null || args.Count == 0)
return null;
var first = (args[0] ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(first))
return null;
if (string.Equals(first, "arm", StringComparison.OrdinalIgnoreCase))
{
if (args.Count < 2)
return null;
var source = (args[1] ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(source))
return null;
return new ParsedArgs(true, source);
}
return new ParsedArgs(false, first);
}
private readonly struct ParsedArgs
{
public ParsedArgs(bool arm, string sourceItemId)
{
Arm = arm;
SourceItemId = sourceItemId;
}
public bool Arm { get; }
public string SourceItemId { get; }
}
}
} |
ifBars
approved these changes
Jan 29, 2026
Owner
ifBars
left a comment
There was a problem hiding this comment.
Thanks for the PR as always, looks great 😄
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 S1API support for assigning a
StationItemprefab to runtime/customStorableItemDefinitionitems, so they can be used as station/minigame ingredients (e.g., Chemistry Station) without mods needing bespoke patches.Design goals:
DontDestroyOnLoadroot by default for Mono + IL2CPP stability.What’s included
S1API.Items.StorableItemDefinitionBuilder.WithStationItem(GameObject stationItemPrefab)StationItemcomponent.StorableItemDefinition.StationItem.IngredientModuleorPourableModule).S1API.Items.StorableItemDefinitionBuilder.WithoutStationItem()S1API.Items.StorableItemDefinition.HasStationItemS1API.Items.StorableItemDefinition.StationItemPrefabS1API/docs/stations.mdto document setting a StationItem for custom ingredients.Usage (recommended)
Set
StationItemduring item creation (ideally duringGameLifecycle.OnPreLoad):Testing
Find the smoke-test console command source file in the first comment under this PR!
s1api_dev_station_item_smoke_test <sourceItemId>s1api_dev_station_item_smoke_test arm <sourceItemId>StorableItemDefinitionand has a validStationItem(used "acid" in tests)WithStationItem(...)Notes / rationale
StorableItemDefinition.StationItem.