Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions S1API/Dialogues/DialogueChoiceListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ public static class DialogueChoiceListener
/// </summary>
private static Action? _callback;

/// <summary>
/// Resets static state so the listener works correctly across save loads.
/// </summary>
internal static void ResetState()
{
_expectedChoiceLabel = null;
_callback = null;
}

/// Registers a specific dialogue choice with a callback to be invoked when the choice is selected.
/// <param name="handlerRef">The reference to the DialogueHandler that manages dialogue choices.</param>
/// <param name="label">The label identifying the specific dialogue choice to be registered.</param>
Expand Down
9 changes: 9 additions & 0 deletions S1API/Dialogues/DialogueInjector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </remarks>
/// <summary>
/// Resets static state so the injector works correctly across save loads.
/// </summary>
internal static void ResetState()
{
_pendingInjections.Clear();
_isHooked = false;
}

private static void HookUpdateLoop()
{
if (_isHooked)
Expand Down
31 changes: 31 additions & 0 deletions S1API/Entities/NPCDealer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ internal NPCDealer(NPC npc)
// Do not assume Dealer exists; prefab may omit it by design
}

/// <summary>
/// Clears stale delegates from the static Dealer.onDealerRecruited field.
/// Must be called on scene change to prevent dead wrapper delegates from accumulating.
/// </summary>
internal static void ClearStaticDelegates()
{
try
{
if (DealerRecruitedField != null)
DealerRecruitedField.SetValue(null, null);
}
catch { }
}

/// <summary>
/// Returns whether this NPC currently has dealer functionality.
/// </summary>
Expand Down Expand Up @@ -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)
{
Expand Down
76 changes: 65 additions & 11 deletions S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
using UnityEngine;
using S1API.Map;
using S1API.Vehicles;
using S1API.Logging;
using S1API.Internal.Utils;

namespace S1API.Entities.Schedule
Expand All @@ -34,6 +35,8 @@ namespace S1API.Entities.Schedule
/// </remarks>
public sealed class DriveToCarParkSpec : IScheduleActionSpec
{
private static readonly Log Logger = new Log("DriveToCarParkSpec");

/// <summary>
/// Gets or sets the time when this action should start, in minutes from midnight.
/// </summary>
Expand Down Expand Up @@ -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)
Expand All @@ -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)
{
Expand All @@ -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);
Expand All @@ -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}");
}
}

/// <summary>
/// INTERNAL: Tracks vehicles assigned to parking lots with no spots.
/// Used by the LandVehicle.Park patch to prevent the game from hiding these vehicles.
/// </summary>
internal static readonly System.Collections.Generic.HashSet<S1Vehicles.LandVehicle> VehiclesAtNoSpotLots =
new System.Collections.Generic.HashSet<S1Vehicles.LandVehicle>();
}
}
23 changes: 23 additions & 0 deletions S1API/Internal/Entities/NPCPrefabIdentity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "<null>"}");
}
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 ?? "<null>"}: {homeEventEx.Message}");
}
}
}
catch (Exception ex)
{
Expand Down
17 changes: 17 additions & 0 deletions S1API/Internal/Lifecycle/SceneStateCleaner.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using S1API.Dialogues;
using S1API.Entities;
using S1API.Avatar;
using S1API.Logging;
Expand Down Expand Up @@ -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
{
Expand Down
15 changes: 15 additions & 0 deletions S1API/Internal/Lifecycle/TimeManagerShim.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ internal class TimeManagerShim

private TimeManagerShim() { }

/// <summary>
/// Clears accumulated delegates so stale references from a previous load don't leak into the next session.
/// </summary>
internal void ResetDelegates()
{
onSleepStart = delegate { };
onHourPass = delegate { };
#if IL2CPPMELON
il2cppOnSleepStart = null;
il2cppOnHourPass = null;
_addedSleepStart.Clear();
_addedHourPass.Clear();
#endif
}

internal void AddDelegatesToReal()
{
try
Expand Down
9 changes: 9 additions & 0 deletions S1API/Internal/Patches/ContactsAppPatches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ internal class ContactsAppPatches
private static bool _startCalled;
private static bool? _hasCustomNpcTypesCache;

/// <summary>
/// Resets static state so the contacts app initializes correctly across save loads.
/// </summary>
internal static void ResetState()
{
_startCalled = false;
_hasCustomNpcTypesCache = null;
}

/// <summary>
/// Checks if any custom NPC types exist (excluding S1API internal types).
/// Caches the result to avoid repeated reflection calls.
Expand Down
Loading
Loading