From 01b05f16f8beec6c0d2fe6e85d713e19fbfbe519 Mon Sep 17 00:00:00 2001 From: k0 <21180271+k073l@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:10:00 +0200 Subject: [PATCH 1/5] fix(phoneapp): wire OnDestroyed Closes #76 --- S1API/PhoneApp/PhoneApp.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs index fbd37f2e..3536acfc 100644 --- a/S1API/PhoneApp/PhoneApp.cs +++ b/S1API/PhoneApp/PhoneApp.cs @@ -648,6 +648,12 @@ private void Update() } } + private void OnDestroy() + { + // Destroy phone app when button handler is destroyed + phoneApp?.DestroyInternal(); + } + private bool IsHoveringButton() { // This is the same logic as native App.IsHoveringButton() From d2563cef4536a773b558391187a2d5c05067235c Mon Sep 17 00:00:00 2001 From: k0 <21180271+k073l@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:48:34 +0200 Subject: [PATCH 2/5] feat(commands): add custom commands to command list --- S1API/Console/CustomConsoleRegistry.cs | 2 +- S1API/Internal/Patches/ConsolePatches.cs | 67 +++++++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/S1API/Console/CustomConsoleRegistry.cs b/S1API/Console/CustomConsoleRegistry.cs index 372bcc35..8b5be8f3 100644 --- a/S1API/Console/CustomConsoleRegistry.cs +++ b/S1API/Console/CustomConsoleRegistry.cs @@ -11,7 +11,7 @@ internal static class CustomConsoleRegistry { private static readonly Logging.Log Logger = new Logging.Log("Console"); - private static readonly Dictionary registry = new Dictionary(StringComparer.OrdinalIgnoreCase); + internal static readonly Dictionary registry = new Dictionary(StringComparer.OrdinalIgnoreCase); internal static void Register(BaseConsoleCommand command) { diff --git a/S1API/Internal/Patches/ConsolePatches.cs b/S1API/Internal/Patches/ConsolePatches.cs index 78a56bb7..f8c6bfa2 100644 --- a/S1API/Internal/Patches/ConsolePatches.cs +++ b/S1API/Internal/Patches/ConsolePatches.cs @@ -4,11 +4,16 @@ using HarmonyLib; using S1API.Console; using S1API.Internal.Utils; +using UnityEngine; +using Object = UnityEngine.Object; #if (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) using S1Console = ScheduleOne.Console; - +using S1CommandListScreen = ScheduleOne.CommandListScreen; +using TMPro; #elif (IL2CPPMELON) using S1Console = Il2CppScheduleOne.Console; +using S1CommandListScreen = Il2CppScheduleOne.CommandListScreen; +using Il2CppTMPro; #endif #if (IL2CPPMELON || IL2CPPBEPINEX) @@ -35,6 +40,7 @@ private static void AddCommands(S1Console __instance) if (__instance == null) return; + _addedCommandsToList.Clear(); var commandTypes = ReflectionUtils.GetDerivedClasses(); foreach (var type in commandTypes) { @@ -134,5 +140,64 @@ private static bool RouteCustomCommandsIl2Cpp(Il2CppSystem.Collections.Generic.L } } #endif + +#if (MONOMELON || MONOBEPINEX) + private static FieldInfo? _commandEntriesField; +#endif + + /// + /// Custom commands that were added to command list screen, stored here to prevent duplicate additions. + /// + private static HashSet _addedCommandsToList = new(); + + /// + /// Adds custom commands to command list screen. + /// + [HarmonyPatch(typeof(S1CommandListScreen), "Start")] + [HarmonyPostfix] + private static void AddCustomCommandEntries(S1CommandListScreen __instance) + { + try + { + if (__instance == null || __instance.CommandEntryPrefab == null || + __instance.CommandEntryContainer == null) + return; +#if (MONOMELON || MONOBEPINEX) + _commandEntriesField ??= + typeof(S1CommandListScreen) + .GetField("commandEntries", BindingFlags.NonPublic | BindingFlags.Instance); + var commandEntries = _commandEntriesField?.GetValue(__instance) as List; +#elif (IL2CPPMELON || IL2CPPBEPINEX) + var commandEntries = __instance?.commandEntries; +#endif + + foreach (var commandKey in CustomConsoleRegistry.registry.Keys) + { + try + { + if (_addedCommandsToList.Contains(commandKey)) + continue; + var rt = Object.Instantiate(__instance.CommandEntryPrefab, __instance.CommandEntryContainer); + rt.Find("Command").GetComponent().text = + CustomConsoleRegistry.registry[commandKey].CommandWord; + rt.Find("Description").GetComponent().text = + CustomConsoleRegistry.registry[commandKey].CommandDescription; + rt.Find("Example").GetComponent().text = + CustomConsoleRegistry.registry[commandKey].ExampleUsage; + + commandEntries?.Add(rt); + _addedCommandsToList.Add(commandKey); + } + catch (Exception e) + { + Logger.Warning($"[Console] Failed to add command '{commandKey}' to command list screen: {e.Message}"); + } + } + } + catch (Exception e) + { + Logger.Warning($"[Console] Failed to add custom commands to command list screen: {e.Message}"); + } + } } } \ No newline at end of file From a8c0dfac5a5aa526e98a394ecd6e4bf2c2ac2208 Mon Sep 17 00:00:00 2001 From: k0 <21180271+k073l@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:50:33 +0200 Subject: [PATCH 3/5] feat(employees): expose employee appearance data Closes #74 --- S1API/Entities/Employees/EmployeeManager.cs | 88 +++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 S1API/Entities/Employees/EmployeeManager.cs diff --git a/S1API/Entities/Employees/EmployeeManager.cs b/S1API/Entities/Employees/EmployeeManager.cs new file mode 100644 index 00000000..3d756571 --- /dev/null +++ b/S1API/Entities/Employees/EmployeeManager.cs @@ -0,0 +1,88 @@ +#if IL2CPPMELON +using S1Employees = Il2CppScheduleOne.Employees; +#elif MONOMELON +using S1Employees = ScheduleOne.Employees; +#endif +using System.Collections.Generic; +using S1API.Avatar; +using UnityEngine; + +namespace S1API.Entities.Employees +{ + /// + /// Provides methods for managing employee appearances and related data. + /// + public static class EmployeeManager + { + private static readonly Logging.Log Logger = new("EmployeeManager"); + + /// + /// Gets an employee appearance by index. + /// + /// Whether to choose from male employee appearance pool + /// The index of the appearance to retrieve + /// An representing the employee appearance at the specified index if successful; otherwise, null. + public static EmployeeAppearance? GetAppearance(bool male, int index) + { + if (!S1Employees.EmployeeManager.InstanceExists) + { + Logger.Error("EmployeeManager instance does not exist; cannot get appearance"); + return null; + } + + return new EmployeeAppearance(S1Employees.EmployeeManager.Instance.GetAppearance(male, index)); + } + + /// + /// Gets a random employee appearance. + /// + /// Whether to choose from male employee appearance pool + /// The index of the appearance that was retrieved + /// The avatar settings of the appearance that was retrieved + /// True if an appearance was successfully retrieved; otherwise, false. + public static bool GetRandomAppearance(bool male, out int index, out AvatarSettings? settings) + { + if (!S1Employees.EmployeeManager.InstanceExists) + { + settings = null; + index = -1; + Logger.Error("EmployeeManager instance does not exist; cannot get random appearance"); + return false; + } + + S1Employees.EmployeeManager.Instance.GetRandomAppearance(male, out var i, out var avatarSettings); + index = i; + settings = new AvatarSettings(avatarSettings); + return true; + } + } + + /// + /// Represents an employee appearance, including avatar settings and mugshot sprite. + /// + public class EmployeeAppearance + { + /// + /// INTERNAL: The underlying employee appearance from the base game. + /// + internal S1Employees.EmployeeManager.EmployeeAppearance S1EmployeeAppearance; + + /// + /// Gets the avatar settings associated with this employee appearance. + /// + public AvatarSettings Settings => new(S1EmployeeAppearance.Settings); + + /// + /// Gets the mugshot sprite associated with this employee appearance. + /// + public Sprite Mugshot => S1EmployeeAppearance.Mugshot; + + /// + /// INTERNAL: Initializes a new instance of the EmployeeAppearance class with the specified base game type appearance. + /// + internal EmployeeAppearance(S1Employees.EmployeeManager.EmployeeAppearance s1EmployeeAppearance) + { + S1EmployeeAppearance = s1EmployeeAppearance; + } + } +} \ No newline at end of file From b6197c2877cac0680f2ffbb2c8589e658b6dc671 Mon Sep 17 00:00:00 2001 From: k0 <21180271+k073l@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:05:18 +0200 Subject: [PATCH 4/5] fix(combat): switch to melonlogger, typecast safely, update xml docs Closes #83 --- S1API/Entities/Behaviour/CombatBehaviour.cs | 28 +++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/S1API/Entities/Behaviour/CombatBehaviour.cs b/S1API/Entities/Behaviour/CombatBehaviour.cs index 55f01d0c..d5572ee1 100644 --- a/S1API/Entities/Behaviour/CombatBehaviour.cs +++ b/S1API/Entities/Behaviour/CombatBehaviour.cs @@ -13,6 +13,7 @@ using UnityEngine; using MelonLoader; using S1API.Entities.Equippables; +using S1API.Internal.Utils; using Object = UnityEngine.Object; namespace S1API.Entities.Behaviour; @@ -22,6 +23,8 @@ namespace S1API.Entities.Behaviour; /// public class CombatBehaviour { + private static readonly Logging.Log Logger = new("CombatBehaviour"); + /// /// INTERNAL: NPC reference /// @@ -57,8 +60,13 @@ public float GiveUpTime /// /// Gets or sets the default weapon asset path for the NPC's combat behaviour. /// This property allows you to specify the weapon that the NPC will use by default. - /// for convenience when setting this property. + /// Use or for convenience when setting this property. + /// Set to an empty string to clear the default weapon. /// + /// + /// Using type-safe + /// or is generally preferred and advised. + /// public string DefaultWeaponAssetPath { get @@ -74,21 +82,27 @@ public string DefaultWeaponAssetPath return; } - var go = Resources.Load(value) as GameObject; - if (go == null) + var go = Resources.Load(value); + if (!CrossType.Is(go, out var gameObject) || gameObject == null) { - Debug.LogError("Could not find weapon at path: " + value); + Logger.Error("Could not find weapon at path: " + value); return; } - var equippable = Object.Instantiate(go).GetComponent(); + var equippable = Object.Instantiate(gameObject).GetComponent(); if (equippable == null) { - Debug.LogError("Could not get AvatarEquippable from weapon at path: " + value); + Logger.Error("Could not get AvatarEquippable from weapon at path: " + value); + return; + } + + if (!CrossType.Is(equippable, out var avatarWeapon)) + { + Logger.Error("AvatarEquippable at path is not an AvatarWeapon: " + value); return; } - NPC.S1NPC.Behaviour.CombatBehaviour.DefaultWeapon = equippable as AvatarWeapon; + NPC.S1NPC.Behaviour.CombatBehaviour.DefaultWeapon = avatarWeapon; } } From 6d00589f6e4b00de6a68cb0058074e932c1513e5 Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 9 Jun 2026 15:37:05 -0700 Subject: [PATCH 5/5] fix(api): harden backlog feature integrations --- S1API/Console/CustomConsoleRegistry.cs | 4 ++- S1API/Entities/Behaviour/CombatBehaviour.cs | 5 +-- S1API/Entities/Employees/EmployeeManager.cs | 12 ++++++-- S1API/Internal/Patches/ConsolePatches.cs | 34 +++++++++++++++++---- S1API/PhoneApp/PhoneApp.cs | 13 ++++++-- 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/S1API/Console/CustomConsoleRegistry.cs b/S1API/Console/CustomConsoleRegistry.cs index 8b5be8f3..9abc762d 100644 --- a/S1API/Console/CustomConsoleRegistry.cs +++ b/S1API/Console/CustomConsoleRegistry.cs @@ -11,7 +11,9 @@ internal static class CustomConsoleRegistry { private static readonly Logging.Log Logger = new Logging.Log("Console"); - internal static readonly Dictionary registry = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary registry = new Dictionary(StringComparer.OrdinalIgnoreCase); + + internal static IReadOnlyDictionary RegisteredCommands => registry; internal static void Register(BaseConsoleCommand command) { diff --git a/S1API/Entities/Behaviour/CombatBehaviour.cs b/S1API/Entities/Behaviour/CombatBehaviour.cs index d5572ee1..af05a165 100644 --- a/S1API/Entities/Behaviour/CombatBehaviour.cs +++ b/S1API/Entities/Behaviour/CombatBehaviour.cs @@ -89,7 +89,8 @@ public string DefaultWeaponAssetPath return; } - var equippable = Object.Instantiate(gameObject).GetComponent(); + var equippable = gameObject.GetComponent() ?? + gameObject.GetComponentInChildren(true); if (equippable == null) { Logger.Error("Could not get AvatarEquippable from weapon at path: " + value); @@ -270,4 +271,4 @@ private string GetAssetPathFromEquippable(Equippable equippable) return avatarEquippable?.AssetPath; } -} \ No newline at end of file +} diff --git a/S1API/Entities/Employees/EmployeeManager.cs b/S1API/Entities/Employees/EmployeeManager.cs index 3d756571..ee346488 100644 --- a/S1API/Entities/Employees/EmployeeManager.cs +++ b/S1API/Entities/Employees/EmployeeManager.cs @@ -30,7 +30,8 @@ public static class EmployeeManager return null; } - return new EmployeeAppearance(S1Employees.EmployeeManager.Instance.GetAppearance(male, index)); + var appearance = S1Employees.EmployeeManager.Instance.GetAppearance(male, index); + return appearance == null ? null : new EmployeeAppearance(appearance); } /// @@ -51,6 +52,13 @@ public static bool GetRandomAppearance(bool male, out int index, out AvatarSetti } S1Employees.EmployeeManager.Instance.GetRandomAppearance(male, out var i, out var avatarSettings); + if (avatarSettings == null) + { + settings = null; + index = -1; + return false; + } + index = i; settings = new AvatarSettings(avatarSettings); return true; @@ -85,4 +93,4 @@ internal EmployeeAppearance(S1Employees.EmployeeManager.EmployeeAppearance s1Emp S1EmployeeAppearance = s1EmployeeAppearance; } } -} \ No newline at end of file +} diff --git a/S1API/Internal/Patches/ConsolePatches.cs b/S1API/Internal/Patches/ConsolePatches.cs index f8c6bfa2..ac395a10 100644 --- a/S1API/Internal/Patches/ConsolePatches.cs +++ b/S1API/Internal/Patches/ConsolePatches.cs @@ -40,7 +40,6 @@ private static void AddCommands(S1Console __instance) if (__instance == null) return; - _addedCommandsToList.Clear(); var commandTypes = ReflectionUtils.GetDerivedClasses(); foreach (var type in commandTypes) { @@ -162,6 +161,8 @@ private static void AddCustomCommandEntries(S1CommandListScreen __instance) if (__instance == null || __instance.CommandEntryPrefab == null || __instance.CommandEntryContainer == null) return; + + _addedCommandsToList.Clear(); #if (MONOMELON || MONOBEPINEX) _commandEntriesField ??= typeof(S1CommandListScreen) @@ -171,19 +172,23 @@ private static void AddCustomCommandEntries(S1CommandListScreen __instance) var commandEntries = __instance?.commandEntries; #endif - foreach (var commandKey in CustomConsoleRegistry.registry.Keys) + foreach (var command in CustomConsoleRegistry.RegisteredCommands) { + var commandKey = command.Key; try { if (_addedCommandsToList.Contains(commandKey)) continue; + if (IsNativeCommand(commandKey)) + continue; + var rt = Object.Instantiate(__instance.CommandEntryPrefab, __instance.CommandEntryContainer); rt.Find("Command").GetComponent().text = - CustomConsoleRegistry.registry[commandKey].CommandWord; + command.Value.CommandWord; rt.Find("Description").GetComponent().text = - CustomConsoleRegistry.registry[commandKey].CommandDescription; + command.Value.CommandDescription; rt.Find("Example").GetComponent().text = - CustomConsoleRegistry.registry[commandKey].ExampleUsage; + command.Value.ExampleUsage; commandEntries?.Add(rt); _addedCommandsToList.Add(commandKey); @@ -199,5 +204,22 @@ private static void AddCustomCommandEntries(S1CommandListScreen __instance) Logger.Warning($"[Console] Failed to add custom commands to command list screen: {e.Message}"); } } + + private static bool IsNativeCommand(string commandKey) + { + if (string.IsNullOrWhiteSpace(commandKey)) + return false; + +#if (MONOMELON || MONOBEPINEX) + _monoCommandsField ??= typeof(S1Console).GetField("commands", BindingFlags.NonPublic | BindingFlags.Static); + var dict = _monoCommandsField?.GetValue(null) as IDictionary; + return dict != null && dict.ContainsKey(commandKey); +#elif (IL2CPPMELON || IL2CPPBEPINEX) + var dict = S1Console.commands; + return dict != null && dict.ContainsKey(commandKey); +#else + return false; +#endif + } } -} \ No newline at end of file +} diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs index 3536acfc..253b3880 100644 --- a/S1API/PhoneApp/PhoneApp.cs +++ b/S1API/PhoneApp/PhoneApp.cs @@ -51,6 +51,9 @@ public abstract class PhoneApp : Registerable /// app canvas structure in the game's Unity hierarchy. /// private GameObject? _appPanel; + private bool _isDestroying; + + internal bool IsDestroying => _isDestroying; /// /// Indicates whether the application UI has been successfully created and initialized. @@ -187,6 +190,11 @@ protected override void OnCreated() /// protected override void OnDestroyed() { + if (_isDestroying) + return; + + _isDestroying = true; + if (_appPanel != null) { Object.Destroy(_appPanel); @@ -651,7 +659,8 @@ private void Update() private void OnDestroy() { // Destroy phone app when button handler is destroyed - phoneApp?.DestroyInternal(); + if (phoneApp != null && !phoneApp.IsDestroying) + phoneApp.DestroyInternal(); } private bool IsHoveringButton() @@ -664,4 +673,4 @@ private bool IsHoveringButton() return false; } } -} \ No newline at end of file +}