diff --git a/S1API/Dialogues/DialogueChoiceListener.cs b/S1API/Dialogues/DialogueChoiceListener.cs index 323a18d5..7bc5583f 100644 --- a/S1API/Dialogues/DialogueChoiceListener.cs +++ b/S1API/Dialogues/DialogueChoiceListener.cs @@ -30,6 +30,15 @@ public static class DialogueChoiceListener /// private static Action? _callback; + /// + /// Resets static state so the listener works correctly across save loads. + /// + internal static void ResetState() + { + _expectedChoiceLabel = null; + _callback = null; + } + /// Registers a specific dialogue choice with a callback to be invoked when the choice is selected. /// The reference to the DialogueHandler that manages dialogue choices. /// The label identifying the specific dialogue choice to be registered. diff --git a/S1API/Dialogues/DialogueInjector.cs b/S1API/Dialogues/DialogueInjector.cs index 98545182..626bf3cf 100644 --- a/S1API/Dialogues/DialogueInjector.cs +++ b/S1API/Dialogues/DialogueInjector.cs @@ -59,6 +59,15 @@ public static void Register(DialogueInjection injection) /// This method prevents multiple hookups by checking if the injection system is already active using an internal flag. /// If not already hooked, the method initializes a coroutine that processes and injects queued dialogue data into the corresponding NPCs. /// + /// + /// Resets static state so the injector works correctly across save loads. + /// + internal static void ResetState() + { + _pendingInjections.Clear(); + _isHooked = false; + } + private static void HookUpdateLoop() { if (_isHooked) diff --git a/S1API/Entities/NPCDealer.cs b/S1API/Entities/NPCDealer.cs index 134540ca..3e01397c 100644 --- a/S1API/Entities/NPCDealer.cs +++ b/S1API/Entities/NPCDealer.cs @@ -74,6 +74,20 @@ internal NPCDealer(NPC npc) // Do not assume Dealer exists; prefab may omit it by design } + /// + /// Clears stale delegates from the static Dealer.onDealerRecruited field. + /// Must be called on scene change to prevent dead wrapper delegates from accumulating. + /// + internal static void ClearStaticDelegates() + { + try + { + if (DealerRecruitedField != null) + DealerRecruitedField.SetValue(null, null); + } + catch { } + } + /// /// Returns whether this NPC currently has dealer functionality. /// @@ -659,6 +673,23 @@ public Map.Building? Home // Set the Home field/property on the Dealer component Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(Component, "Home", homeBuilding); + + // Sync HomeEvent.Building — Dealer.Awake() does this but runs before + // Home is set for S1API NPCs, so we must do it here too. + try + { +#if MONOMELON + var homeEventField = typeof(S1Economy.Dealer).GetField("HomeEvent", BindingFlags.Public | BindingFlags.Instance); +#else + var homeEventField = typeof(S1Economy.Dealer).GetProperty("HomeEvent", BindingFlags.Public | BindingFlags.Instance); +#endif + var homeEvent = homeEventField?.GetValue(Component); + if (homeEvent != null) + { + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(homeEvent, "Building", homeBuilding); + } + } + catch { } } catch (Exception ex) { diff --git a/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs b/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs index a2de7de8..77ffb18e 100644 --- a/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs +++ b/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs @@ -20,6 +20,7 @@ using UnityEngine; using S1API.Map; using S1API.Vehicles; +using S1API.Logging; using S1API.Internal.Utils; namespace S1API.Entities.Schedule @@ -34,6 +35,8 @@ namespace S1API.Entities.Schedule /// public sealed class DriveToCarParkSpec : IScheduleActionSpec { + private static readonly Log Logger = new Log("DriveToCarParkSpec"); + /// /// Gets or sets the time when this action should start, in minutes from midnight. /// @@ -177,23 +180,41 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule) { // Resolve lot object lotObj = null; + S1Map.ParkingLot gameLot = null; if (ParkingLot != null) { - lotObj = ParkingLot.ResolveGameLot(); + gameLot = ParkingLot.ResolveGameLot(); + lotObj = gameLot; } else if (!string.IsNullOrEmpty(ParkingLotName)) { var lotWrap = ParkingLotRegistry.GetByName(ParkingLotName); - lotObj = lotWrap?.ResolveGameLot(); + gameLot = lotWrap?.ResolveGameLot(); + lotObj = gameLot; } else if (!string.IsNullOrEmpty(ParkingLotGUID)) { var lotWrap = ParkingLotRegistry.GetByGUID(ParkingLotGUID); - lotObj = lotWrap?.ResolveGameLot(); + gameLot = lotWrap?.ResolveGameLot(); + lotObj = gameLot; + } + + if (lotObj == null) + { + Logger.Warning($"DriveToCarPark: Parking lot could not be resolved (Name='{ParkingLotName}', GUID='{ParkingLotGUID}'). Action will not function correctly."); } - if (lotObj != null) + else + { ReflectionUtils.TrySetFieldOrProperty(action, "ParkingLot", lotObj); + // Warn if the lot has no parking spots - vehicles parked here will be hidden by the game + if (gameLot != null && (gameLot.ParkingSpots == null || gameLot.ParkingSpots.Count == 0)) + { + Logger.Warning($"DriveToCarPark: Parking lot '{gameLot.gameObject.name}' has no parking spots. " + + "The game will hide (SetVisible=false) any vehicle parked here. Choose a lot with ParkingSpot children."); + } + } + // Resolve vehicle object vehObj = null; if (Vehicle != null && Vehicle.S1LandVehicle != null) @@ -213,8 +234,15 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule) else if (!string.IsNullOrEmpty(VehicleCode)) { var v = VehicleRegistry.CreateVehicle(VehicleCode); - vehObj = v?.S1LandVehicle; - + if (v == null) + { + Logger.Error($"DriveToCarPark: Failed to create vehicle with code '{VehicleCode}'. Verify the code is valid."); + } + else + { + vehObj = v.S1LandVehicle; + } + // If we're on the server and the vehicle needs spawning, spawn it now if (vehObj != null) { @@ -228,24 +256,39 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule) #else var nm = Il2CppFishNet.InstanceFinder.NetworkManager; #endif - + if (nm != null && nm.IsServer) { - // Spawn the vehicle at the specified location and rotation var spawnRot = VehicleSpawnRotation ?? Quaternion.identity; wrapper.Spawn(VehicleSpawnPosition, spawnRot); } } catch (Exception ex) { - UnityEngine.Debug.LogWarning($"[S1API] Failed to spawn created vehicle: {ex.Message}"); + Logger.Error($"DriveToCarPark: Failed to spawn created vehicle '{VehicleCode}': {ex.Message}"); + Logger.Error($"DriveToCarPark: Stack trace: {ex.StackTrace}"); } } } } - if (vehObj != null) + + if (vehObj == null) + { + Logger.Warning("DriveToCarPark: Vehicle could not be resolved. The NPC will not be able to drive."); + } + else + { ReflectionUtils.TrySetFieldOrProperty(action, "Vehicle", vehObj); + // Track vehicles created for lots with no spots so the patch can prevent hiding + if (gameLot != null && (gameLot.ParkingSpots == null || gameLot.ParkingSpots.Count == 0)) + { + var gameVeh = vehObj as S1Vehicles.LandVehicle; + if (gameVeh != null) + VehiclesAtNoSpotLots.Add(gameVeh); + } + } + // Flags if (OverrideParkingType.HasValue) ReflectionUtils.TrySetFieldOrProperty(action, "OverrideParkingType", OverrideParkingType.Value); @@ -261,7 +304,18 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule) ReflectionUtils.TrySetFieldOrProperty(action, "ParkingType", boxed); } } - catch { } + catch (Exception ex) + { + Logger.Error($"DriveToCarPark: Unexpected error during ApplyTo: {ex.Message}"); + Logger.Error($"DriveToCarPark: Stack trace: {ex.StackTrace}"); + } } + + /// + /// INTERNAL: Tracks vehicles assigned to parking lots with no spots. + /// Used by the LandVehicle.Park patch to prevent the game from hiding these vehicles. + /// + internal static readonly System.Collections.Generic.HashSet VehiclesAtNoSpotLots = + new System.Collections.Generic.HashSet(); } } diff --git a/S1API/Internal/Entities/NPCPrefabIdentity.cs b/S1API/Internal/Entities/NPCPrefabIdentity.cs index e2368de2..8b801041 100644 --- a/S1API/Internal/Entities/NPCPrefabIdentity.cs +++ b/S1API/Internal/Entities/NPCPrefabIdentity.cs @@ -662,6 +662,29 @@ private void ApplyDealerHomeBuilding(S1NPCs.NPC npc, string buildingName = null) { Logger.Warning($"[Dealer Home] Failed to set Home property on Dealer component for NPC {npc?.ID ?? ""}"); } + else + { + // Dealer.Awake() runs HomeEvent.Building = Home, but for S1API NPCs + // Home is null at Awake-time (resolved later). We must sync HomeEvent.Building + // so the schedule system knows where to send the dealer. + try + { +#if MONOMELON + var homeEventField = typeof(S1Economy.Dealer).GetField("HomeEvent", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); +#else + var homeEventField = typeof(S1Economy.Dealer).GetProperty("HomeEvent", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); +#endif + var homeEvent = homeEventField?.GetValue(dealerComponent); + if (homeEvent != null) + { + ReflectionUtils.TrySetFieldOrProperty(homeEvent, "Building", gameBuilding); + } + } + catch (Exception homeEventEx) + { + Logger.Warning($"[Dealer Home] Failed to sync HomeEvent.Building for NPC {npc?.ID ?? ""}: {homeEventEx.Message}"); + } + } } catch (Exception ex) { diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs index 8be240ac..bfa05786 100644 --- a/S1API/Internal/Lifecycle/SceneStateCleaner.cs +++ b/S1API/Internal/Lifecycle/SceneStateCleaner.cs @@ -1,4 +1,5 @@ using System; +using S1API.Dialogues; using S1API.Entities; using S1API.Avatar; using S1API.Logging; @@ -100,6 +101,22 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) // Reset loading screen patch state to prevent stuck flags LoadingScreenPatches.ResetState(); + + // Reset dialogue system static state to prevent stale injections/callbacks + DialogueInjector.ResetState(); + DialogueChoiceListener.ResetState(); + + // Reset contacts app state so it re-initializes properly on next load + ContactsAppPatches.ResetState(); + + // Clear stale NPC patch state (dangling Customer references, loading guards) + NPCPatches.ResetState(); + + // Clear stale delegates from Dealer.onDealerRecruited static field + NPCDealer.ClearStaticDelegates(); + + // Clear accumulated shim delegates to prevent stale references on next load + TimeManagerShim.Instance.ResetDelegates(); } else { diff --git a/S1API/Internal/Lifecycle/TimeManagerShim.cs b/S1API/Internal/Lifecycle/TimeManagerShim.cs index b3978214..19367868 100644 --- a/S1API/Internal/Lifecycle/TimeManagerShim.cs +++ b/S1API/Internal/Lifecycle/TimeManagerShim.cs @@ -31,6 +31,21 @@ internal class TimeManagerShim private TimeManagerShim() { } + /// + /// Clears accumulated delegates so stale references from a previous load don't leak into the next session. + /// + internal void ResetDelegates() + { + onSleepStart = delegate { }; + onHourPass = delegate { }; +#if IL2CPPMELON + il2cppOnSleepStart = null; + il2cppOnHourPass = null; + _addedSleepStart.Clear(); + _addedHourPass.Clear(); +#endif + } + internal void AddDelegatesToReal() { try diff --git a/S1API/Internal/Patches/ContactsAppPatches.cs b/S1API/Internal/Patches/ContactsAppPatches.cs index bbc438b4..dcb0e02d 100644 --- a/S1API/Internal/Patches/ContactsAppPatches.cs +++ b/S1API/Internal/Patches/ContactsAppPatches.cs @@ -39,6 +39,15 @@ internal class ContactsAppPatches private static bool _startCalled; private static bool? _hasCustomNpcTypesCache; + /// + /// Resets static state so the contacts app initializes correctly across save loads. + /// + internal static void ResetState() + { + _startCalled = false; + _hasCustomNpcTypesCache = null; + } + /// /// Checks if any custom NPC types exist (excluding S1API internal types). /// Caches the result to avoid repeated reflection calls. diff --git a/S1API/Internal/Patches/DealerManagementAppPatches.cs b/S1API/Internal/Patches/DealerManagementAppPatches.cs new file mode 100644 index 00000000..847f1d68 --- /dev/null +++ b/S1API/Internal/Patches/DealerManagementAppPatches.cs @@ -0,0 +1,134 @@ +#if (IL2CPPMELON) +using S1DealerManagementApp = Il2CppScheduleOne.UI.Phone.Messages.DealerManagementApp; +using S1Economy = Il2CppScheduleOne.Economy; +#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) +using S1DealerManagementApp = ScheduleOne.UI.Phone.Messages.DealerManagementApp; +using S1Economy = ScheduleOne.Economy; +#endif + +using System; +using System.Reflection; +using HarmonyLib; +using S1API.Internal.Utils; + +namespace S1API.Internal.Patches +{ + /// + /// INTERNAL: Patches DealerManagementApp to refresh the dropdown when opened. + /// Fixes stale mugshot sprites and region text for custom S1API dealers whose + /// properties are set in OnCreated (after the dropdown was initially populated). + /// + [HarmonyPatch] + internal static class DealerManagementAppPatches + { + private static readonly Logging.Log Logger = new Logging.Log("DealerManagementAppPatches"); + + #region Private Implementation + + /// + /// Calls the private RefreshDropdown method and restores the selected dealer index. + /// + private static void RefreshAndSyncDropdown(S1DealerManagementApp instance) + { + var selectedDealer = instance.SelectedDealer; + +#if (IL2CPPMELON) + try + { + instance.RefreshDropdown(); + } + catch + { + var method = typeof(S1DealerManagementApp).GetMethod("RefreshDropdown", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); + method?.Invoke(instance, null); + } +#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) + var method = typeof(S1DealerManagementApp).GetMethod("RefreshDropdown", + BindingFlags.NonPublic | BindingFlags.Instance); + if (method == null) + return; + method.Invoke(instance, null); +#endif + + if (selectedDealer == null) + return; + +#if (IL2CPPMELON) + var dealers = instance.dealers; + if (dealers == null) + return; + + int index = -1; + for (int i = 0; i < dealers.Count; i++) + { + if (dealers[i] == selectedDealer) + { + index = i; + break; + } + } + + if (index >= 0) + instance._dropdown?.SetValueWithoutNotify(index); +#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) + var dealersObj = ReflectionUtils.TryGetFieldOrProperty(instance, "dealers"); + var dealers = dealersObj as System.Collections.Generic.List; + if (dealers == null) + return; + + int index = dealers.IndexOf(selectedDealer); + if (index < 0) + return; + + var dropdownObj = ReflectionUtils.TryGetFieldOrProperty(instance, "_dropdown"); + var dropdown = dropdownObj as UnityEngine.UI.Dropdown; + if (dropdown != null) + dropdown.SetValueWithoutNotify(index); +#endif + } + + #endregion + + #region Harmony Patches + + /// + /// After SetOpen(true), rebuild the dropdown so it reflects current MugshotSprite and Region values. + /// + [HarmonyPatch(typeof(S1DealerManagementApp), nameof(S1DealerManagementApp.SetOpen))] + [HarmonyPostfix] + private static void SetOpen_Postfix(S1DealerManagementApp __instance, bool open) + { + if (!open) + return; + + try + { + RefreshAndSyncDropdown(__instance); + } + catch (Exception ex) + { + Logger.Warning($"Failed to refresh dealer dropdown on open: {ex.Message}"); + } + } + + /// + /// After Refresh(), rebuild the dropdown so it reflects current values when the phone is reopened. + /// + [HarmonyPatch(typeof(S1DealerManagementApp), nameof(S1DealerManagementApp.Refresh))] + [HarmonyPostfix] + private static void Refresh_Postfix(S1DealerManagementApp __instance) + { + try + { + RefreshAndSyncDropdown(__instance); + } + catch (Exception ex) + { + Logger.Warning($"Failed to refresh dealer dropdown on refresh: {ex.Message}"); + } + } + + #endregion + } +} diff --git a/S1API/Internal/Patches/LandVehiclePatches.cs b/S1API/Internal/Patches/LandVehiclePatches.cs index 6d79ab82..e38c4543 100644 --- a/S1API/Internal/Patches/LandVehiclePatches.cs +++ b/S1API/Internal/Patches/LandVehiclePatches.cs @@ -1,7 +1,8 @@ using HarmonyLib; using S1API.Vehicles; +using S1API.Entities.Schedule; -#if (IL2CPPMELON || Il2CppBepInEx) +#if (IL2CPPMELON || IL2CPPBEPINEX) using S1Vehicles = Il2CppScheduleOne.Vehicles; #else using S1Vehicles = ScheduleOne.Vehicles; @@ -18,7 +19,10 @@ public static void OnDestroy(S1Vehicles.LandVehicle __instance) { try { if (__instance != null) + { VehicleRegistry.RemoveVehicle(__instance.GUID.ToString()); + DriveToCarParkSpec.VehiclesAtNoSpotLots.Remove(__instance); + } } catch { @@ -26,5 +30,28 @@ public static void OnDestroy(S1Vehicles.LandVehicle __instance) { } } + /// + /// Prevents the game from hiding vehicles that were assigned to parking lots with no + /// parking spots. When spotIndex is invalid, the game calls SetVisible(false). This + /// prefix intercepts that call and keeps the vehicle visible instead, leaving it + /// wherever it currently is (e.g. where the NPC drove it). + /// + [HarmonyPatch(nameof(S1Vehicles.LandVehicle.SetVisible))] + [HarmonyPrefix] + public static bool SetVisible_Prefix(S1Vehicles.LandVehicle __instance, ref bool vis) + { + try + { + if (!vis && __instance != null && DriveToCarParkSpec.VehiclesAtNoSpotLots.Contains(__instance)) + { + vis = true; + } + } + catch + { + // Don't break game visibility if our fix-up fails + } + return true; + } } } diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 8e58dc55..61964d84 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -74,6 +74,15 @@ internal class NPCPatches private static readonly System.Collections.Generic.Dictionary _savedCurrentAddiction = new System.Collections.Generic.Dictionary(); + /// + /// Resets static state that can leak across save loads. + /// + internal static void ResetState() + { + _savedCurrentAddiction.Clear(); + _loadingDealers.Clear(); + } + /// /// Comparison function for sorting NPCAction by StartTime, with signals coming before non-signals when times are equal. /// diff --git a/S1API/Internal/Patches/StoragePatches.cs b/S1API/Internal/Patches/StoragePatches.cs index 7c609036..069fe600 100644 --- a/S1API/Internal/Patches/StoragePatches.cs +++ b/S1API/Internal/Patches/StoragePatches.cs @@ -49,6 +49,41 @@ private class StorageSlotMeta : S1Persistence.SaveData public string ItemId; } + /// + /// Manually parse the custom name from RenamableConfigurationData JSON. + /// Handles both compact and pretty-printed formats. + /// + private static string ParseConfigurationName(string json) + { + if (string.IsNullOrEmpty(json)) + return null; + + try + { + int valueKeyIdx = json.IndexOf("\"Value\""); + if (valueKeyIdx < 0) + return null; + + int colonIdx = json.IndexOf(':', valueKeyIdx + 7); + if (colonIdx < 0) + return null; + + int openQuote = json.IndexOf('"', colonIdx + 1); + if (openQuote < 0) + return null; + + int closeQuote = json.IndexOf('"', openQuote + 1); + if (closeQuote < 0) + return null; + + return json.Substring(openQuote + 1, closeQuote - openQuote - 1); + } + catch + { + return null; + } + } + /// /// Manually parse StorageSlotMeta from JSON to avoid IL2CPP generic method issues. /// @@ -335,6 +370,25 @@ private static bool PlaceableStorageEntityLoader_Load_Prefix(S1PersistenceLoader wrapper.SetSlotCount(targetSlots); storageData.Contents.LoadTo(placeableStorage.StorageEntity.ItemSlots); + + // Load the Configuration (custom name) from save data. + // The original loader does this deferred via onLoadComplete, but we apply it + // directly since Configuration is already initialized after LoadAndCreate. + if (data.TryGetData("Configuration", out string configJson) && !string.IsNullOrEmpty(configJson)) + { + try + { + var configName = ParseConfigurationName(configJson); + if (!string.IsNullOrEmpty(configName) && placeableStorage.Configuration?.Name != null) + { + placeableStorage.Configuration.Name.SetValue(configName, true); + } + } + catch (Exception configEx) + { + Logger.Warning($"Failed to load storage configuration name: {configEx.Message}"); + } + } } catch (Exception ex) {