diff --git a/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs b/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs new file mode 100644 index 00000000..12e88faf --- /dev/null +++ b/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; +using S1API.Entities; +using S1API.Vehicles; + +namespace S1API.Tests.Entities; + +public sealed class NPCVehicleLifecycleApiTests +{ + [Theory] + [InlineData(nameof(NPC.OnEnterVehicle))] + [InlineData(nameof(NPC.OnExitVehicle))] + public void VehicleLifecycleEventsExposeManagedVehicleArguments(string eventName) + { + EventInfo? eventInfo = typeof(NPC).GetEvent(eventName); + + Assert.NotNull(eventInfo); + Assert.Equal(typeof(Action), eventInfo!.EventHandlerType); + } +} + +internal static class NPCVehicleLifecycleApiCompileFixture +{ + internal static void SubscribeAndUnsubscribe(NPC npc) + { + Action handler = _ => { }; + + npc.OnEnterVehicle += handler; + npc.OnExitVehicle += handler; + npc.OnEnterVehicle -= handler; + npc.OnExitVehicle -= handler; + } +} diff --git a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs index 48cd4799..3040f28f 100644 --- a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs +++ b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs @@ -19,4 +19,28 @@ public void DuplicateAddsAreRemovedOneAtATimeInReverseRegistrationOrder() Assert.Equal("first", first); Assert.False(tracker.TryTakeLast(handler, out _)); } + + [Fact] + public void TakeAllReturnsEveryRegistrationAndClearsTheTracker() + { + var tracker = new ManagedEventRegistrationTracker(); + Action firstHandler = () => { }; + Action secondHandler = _ => { }; + + tracker.Add(firstHandler, "first"); + tracker.Add(firstHandler, "second"); + tracker.Add(secondHandler, "third"); + + var registrations = tracker.TakeAll(); + + Assert.Equal(3, registrations.Count); + Assert.Contains(registrations, registration => + registration.ManagedHandler.Equals(firstHandler) && registration.NativeHandler == "first"); + Assert.Contains(registrations, registration => + registration.ManagedHandler.Equals(firstHandler) && registration.NativeHandler == "second"); + Assert.Contains(registrations, registration => + registration.ManagedHandler.Equals(secondHandler) && registration.NativeHandler == "third"); + Assert.False(tracker.TryTakeLast(firstHandler, out _)); + Assert.False(tracker.TryTakeLast(secondHandler, out _)); + } } diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index ff8bbc5e..2116ee6d 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1,4 +1,5 @@ #if (IL2CPPMELON) +using NativeVehicleLifecycleAction = Il2CppSystem.Action; using S1DevUtilities = Il2CppScheduleOne.DevUtilities; using S1AvatarEquipping = Il2CppScheduleOne.AvatarFramework.Equipping; using S1Dialogue = Il2CppScheduleOne.Dialogue; @@ -28,6 +29,7 @@ using S1Money = Il2CppScheduleOne.Money; using ConversationCategoryList = Il2CppSystem.Collections.Generic.List; #elif MONOMELON +using NativeVehicleLifecycleAction = System.Action; using S1DevUtilities = ScheduleOne.DevUtilities; using S1AvatarEquipping = ScheduleOne.AvatarFramework.Equipping; using S1Dialogue = ScheduleOne.Dialogue; @@ -2817,6 +2819,44 @@ public Map.Building? CurrentBuilding public LandVehicle? CurrentVehicle => S1NPC.CurrentVehicle != null ? new LandVehicle(S1NPC.CurrentVehicle) : null; + /// + /// Occurs when the NPC enters a vehicle. + /// + /// This event preserves the timing of the native vehicle-entry callback. + public event Action OnEnterVehicle + { + add => AddVehicleLifecycleHandler( + value, + _enterVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), + SubscribeEnterVehicle, + nameof(OnEnterVehicle)); + remove => RemoveVehicleLifecycleHandler( + value, + _enterVehicleRegistrations, + UnsubscribeEnterVehicle, + nameof(OnEnterVehicle)); + } + + /// + /// Occurs when the NPC exits a vehicle. + /// + /// This event preserves the timing of the native vehicle-exit callback. + public event Action OnExitVehicle + { + add => AddVehicleLifecycleHandler( + value, + _exitVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), + SubscribeExitVehicle, + nameof(OnExitVehicle)); + remove => RemoveVehicleLifecycleHandler( + value, + _exitVehicleRegistrations, + UnsubscribeExitVehicle, + nameof(OnExitVehicle)); + } + // TODO: Add Inventory (currently missing NPCInventory abstraction) // public ??? Inventory { get; set; } @@ -2949,12 +2989,6 @@ public void ClearConversationCategories() } } - // TODO: Add OnEnterVehicle listener (currently missing LandVehicle abstraction) - // public event Action OnEnterVehicle { } - - // TODO: Add OnExitVehicle listener (currently missing LandVehicle abstraction) - // public event Action OnExitVehicle { } - // TODO: Add OnExplosionHeard listener (currently missing NoiseEvent abstraction) // public event Action OnExplosionHeard { } @@ -4146,6 +4180,8 @@ private void RestoreRuntimeAvatarAppearance() private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; + private ManagedEventRegistrationTracker? _enterVehicleRegistrations; + private ManagedEventRegistrationTracker? _exitVehicleRegistrations; private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4685,6 +4721,164 @@ internal void CleanupRuntimeHooks() { ClearDealerRecommendationHooks(); _messaging?.Cleanup(); + CleanupVehicleLifecycleHooks(); + } + + private void AddVehicleLifecycleHandler( + Action? handler, + ManagedEventRegistrationTracker registrations, + Action subscribe, + string eventName) + { + if (handler == null) + return; + + try + { + NativeVehicleLifecycleAction nativeHandler = + CreateVehicleLifecycleHandler(handler, eventName); + subscribe(nativeHandler); + registrations.Add(handler, nativeHandler); + } + catch (Exception ex) + { + Logger.Warning( + $"Could not subscribe to NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + + private void RemoveVehicleLifecycleHandler( + Action? handler, + ManagedEventRegistrationTracker? registrations, + Action unsubscribe, + string eventName) + { + if (handler == null || registrations == null || + !registrations.TryTakeLast(handler, out var nativeHandler)) + return; + + try + { + unsubscribe(nativeHandler); + } + catch (Exception ex) + { + registrations.Add(handler, nativeHandler); + Logger.Warning( + $"Could not unsubscribe from NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + + private NativeVehicleLifecycleAction CreateVehicleLifecycleHandler( + Action handler, + string eventName) + { + Action managedHandler = vehicle => + { + try + { + handler(new LandVehicle(vehicle)); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC.{eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + } + }; + +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(managedHandler) + ?? throw new InvalidOperationException( + $"Could not create the native {eventName} delegate."); +#else + return managedHandler; +#endif + } + + private void SubscribeEnterVehicle(NativeVehicleLifecycleAction handler) + { +#if IL2CPPMELON + S1NPC.onEnterVehicle = S1NPC.onEnterVehicle == null + ? handler + : Il2CppSystem.Delegate.Combine(S1NPC.onEnterVehicle, handler) + .Cast(); +#else + S1NPC.onEnterVehicle += handler; +#endif + } + + private void UnsubscribeEnterVehicle(NativeVehicleLifecycleAction handler) + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1NPC.onEnterVehicle, + handler); + S1NPC.onEnterVehicle = remaining?.Cast(); +#else + S1NPC.onEnterVehicle -= handler; +#endif + } + + private void SubscribeExitVehicle(NativeVehicleLifecycleAction handler) + { +#if IL2CPPMELON + S1NPC.onExitVehicle = S1NPC.onExitVehicle == null + ? handler + : Il2CppSystem.Delegate.Combine(S1NPC.onExitVehicle, handler) + .Cast(); +#else + S1NPC.onExitVehicle += handler; +#endif + } + + private void UnsubscribeExitVehicle(NativeVehicleLifecycleAction handler) + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1NPC.onExitVehicle, + handler); + S1NPC.onExitVehicle = remaining?.Cast(); +#else + S1NPC.onExitVehicle -= handler; +#endif + } + + private void CleanupVehicleLifecycleHooks() + { + CleanupVehicleLifecycleHandlers( + _enterVehicleRegistrations, + UnsubscribeEnterVehicle, + nameof(OnEnterVehicle)); + CleanupVehicleLifecycleHandlers( + _exitVehicleRegistrations, + UnsubscribeExitVehicle, + nameof(OnExitVehicle)); + } + + private void CleanupVehicleLifecycleHandlers( + ManagedEventRegistrationTracker? registrations, + Action unsubscribe, + string eventName) + { + if (registrations == null) + return; + + foreach (var registration in registrations.TakeAll()) + { + try + { + unsubscribe(registration.NativeHandler); + } + catch (Exception ex) + { + registrations.Add( + registration.ManagedHandler, + registration.NativeHandler); + Logger.Warning( + $"Could not clean up NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } } private sealed class DealerRecommendationSubscription diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs index 1bdf9ca0..b5a3f704 100644 --- a/S1API/Internal/Lifecycle/SceneStateCleaner.cs +++ b/S1API/Internal/Lifecycle/SceneStateCleaner.cs @@ -66,9 +66,14 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) for (int i = 0; i < NPC.All.Count; i++) { var npc = NPC.All[i]; - if (npc != null && npc.gameObject != null) + if (npc != null) { - TryRun(() => UnityEngine.Object.Destroy(npc.gameObject)); + TryRun( + npc.CleanupRuntimeHooks, + "Failed to remove NPC runtime hooks during scene cleanup"); + + if (npc.gameObject != null) + TryRun(() => UnityEngine.Object.Destroy(npc.gameObject)); } } NPC.All.Clear(); @@ -126,4 +131,3 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) } } } - diff --git a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs index 669bbda8..09c8926e 100644 --- a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs +++ b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs @@ -9,9 +9,9 @@ namespace S1API.Internal.Utils /// The runtime-specific handler type. internal sealed class ManagedEventRegistrationTracker { - private readonly Dictionary> _registrations = new Dictionary>(); + private readonly Dictionary> _registrations = new Dictionary>(); - internal void Add(Action managedHandler, TNativeHandler nativeHandler) + internal void Add(Delegate managedHandler, TNativeHandler nativeHandler) { if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers)) { @@ -22,7 +22,7 @@ internal void Add(Action managedHandler, TNativeHandler nativeHandler) nativeHandlers.Add(nativeHandler); } - internal bool TryTakeLast(Action managedHandler, out TNativeHandler nativeHandler) + internal bool TryTakeLast(Delegate managedHandler, out TNativeHandler nativeHandler) { if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers) || nativeHandlers.Count == 0) @@ -41,5 +41,20 @@ internal bool TryTakeLast(Action managedHandler, out TNativeHandler nativeHandle return true; } + + internal IReadOnlyList<(Delegate ManagedHandler, TNativeHandler NativeHandler)> TakeAll() + { + var registrations = new List<(Delegate ManagedHandler, TNativeHandler NativeHandler)>(); + foreach (var registration in _registrations) + { + foreach (TNativeHandler nativeHandler in registration.Value) + { + registrations.Add((registration.Key, nativeHandler)); + } + } + + _registrations.Clear(); + return registrations; + } } }