Skip to content

items: add StationItem support for station/minigame ingredients#42

Merged
ifBars merged 1 commit intoifBars:stablefrom
Khundiann:items/add-StationItem-support-for-station/minigame-ingredients
Jan 29, 2026
Merged

items: add StationItem support for station/minigame ingredients#42
ifBars merged 1 commit intoifBars:stablefrom
Khundiann:items/add-StationItem-support-for-station/minigame-ingredients

Conversation

@Khundiann
Copy link

Summary

This PR adds S1API support for assigning a StationItem prefab to runtime/custom StorableItemDefinition items, so they can be used as station/minigame ingredients (e.g., Chemistry Station) without mods needing bespoke patches.

Design goals:

  • Builder-first: station usability is configured at build/registration time.
  • Cross-runtime stable: StationItem prefabs are cloned and cached under a hidden DontDestroyOnLoad root by default for Mono + IL2CPP stability.
  • Minimal API: wrappers remain read-only; this is an explicit opt-in builder feature.

What’s included

  • New item builder API:
    • S1API.Items.StorableItemDefinitionBuilder.WithStationItem(GameObject stationItemPrefab)
      • Validates the provided prefab is non-null and has a StationItem component.
      • Clones + caches a stable hidden instance and assigns it to StorableItemDefinition.StationItem.
      • Warns once per assigned prefab if it appears incompatible with Chemistry (no IngredientModule or PourableModule).
    • S1API.Items.StorableItemDefinitionBuilder.WithoutStationItem()
  • Wrapper additions (read-only inspection):
    • S1API.Items.StorableItemDefinition.HasStationItem
    • S1API.Items.StorableItemDefinition.StationItemPrefab
  • Docs:
    • Updated S1API/docs/stations.md to document setting a StationItem for custom ingredients.

Usage (recommended)

Set StationItem during item creation (ideally during GameLifecycle.OnPreLoad):

using S1API.Items;
using UnityEngine;

GameObject myIngredientStationItemPrefab = ...; // StationItem prefab loaded from an AssetBundle

var ingredient = ItemCreator.CreateBuilder()
    .WithBasicInfo("mymod_custom_ingredient", "Custom Ingredient", "Used in stations.", ItemCategory.Consumable)
    .WithStationItem(myIngredientStationItemPrefab)
    .Build();

Testing

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

  • Added DEV-local smoke test command for local verification:
    • s1api_dev_station_item_smoke_test <sourceItemId>
    • s1api_dev_station_item_smoke_test arm <sourceItemId>
  • Validates:
    • Base-game source item resolves to a StorableItemDefinition and has a valid StationItem (used "acid" in tests)
    • Runtime storable item can be created with WithStationItem(...)
    • A Chemistry Station recipe can be registered that uses the runtime item as an ingredient (builder validation passes)
  • Verified on Mono and IL2CPP.

Notes / rationale

  • The base game spawns station/minigame ingredient props by instantiating StorableItemDefinition.StationItem.
  • Without a StationItem, station tasks can log errors and may skip or break ingredient handling.
  • Cloning + caching avoids mutating shared prefabs and keeps StationItem references stable across scene loads.

@Khundiann
Copy link
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; }
        }
    }
}

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.

Thanks for the PR as always, looks great 😄

@ifBars ifBars added the enhancement New feature or request label Jan 29, 2026
@ifBars ifBars self-assigned this Jan 29, 2026
@ifBars ifBars merged commit 1289eaf into ifBars:stable Jan 29, 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