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/4] 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 fbd37f2..3536acf 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/4] 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 372bcc3..8b5be8f 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 78a56bb..f8c6bfa 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/4] 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 0000000..3d75657 --- /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/4] 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 55f01d0..d5572ee 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; } }