diff --git a/S1API.Tests/Entities/NPCAwarenessApiTests.cs b/S1API.Tests/Entities/NPCAwarenessApiTests.cs new file mode 100644 index 00000000..d601461e --- /dev/null +++ b/S1API.Tests/Entities/NPCAwarenessApiTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Reflection; +using S1API.Entities; +using S1API.Vehicles; + +namespace S1API.Tests.Entities; + +public sealed class NPCAwarenessApiTests +{ + [Theory] + [InlineData(nameof(NPCNoiseEvent.Origin), "UnityEngine.Vector3")] + [InlineData(nameof(NPCNoiseEvent.Range), "System.Single")] + [InlineData(nameof(NPCNoiseEvent.Type), "S1API.Entities.NPCNoiseType")] + [InlineData(nameof(NPCNoiseEvent.Source), "UnityEngine.GameObject")] + [InlineData(nameof(NPCNoiseEvent.OriginInSewer), "System.Boolean")] + public void NoiseSnapshotPropertiesAreReadOnly(string propertyName, string propertyTypeName) + { + PropertyInfo? property = typeof(NPCNoiseEvent).GetProperty(propertyName); + + Assert.NotNull(property); + Assert.Equal(propertyTypeName, property!.PropertyType.FullName); + Assert.False(property.CanWrite); + } + + [Fact] + public void NoiseTypesRetainNativeValues() + { + Assert.Equal(0, (int)NPCNoiseType.Footstep); + Assert.Equal(1, (int)NPCNoiseType.Gunshot); + Assert.Equal(2, (int)NPCNoiseType.Explosion); + } + + [Theory] + [InlineData(nameof(NPC.OnNoticedDrugDealing), typeof(Player))] + [InlineData(nameof(NPC.OnNoticedGeneralCrime), typeof(Player))] + [InlineData(nameof(NPC.OnNoticedPettyCrime), typeof(Player))] + [InlineData(nameof(NPC.OnNoticedPlayerViolatingCurfew), typeof(Player))] + [InlineData(nameof(NPC.OnNoticedSuspiciousPlayer), typeof(Player))] + [InlineData(nameof(NPC.OnGunshotHeard), typeof(NPCNoiseEvent))] + [InlineData(nameof(NPC.OnExplosionHeard), typeof(NPCNoiseEvent))] + [InlineData(nameof(NPC.OnHitByCar), typeof(LandVehicle))] + public void AwarenessEventsExposeManagedArguments(string eventName, Type argumentType) + { + EventInfo? eventInfo = typeof(NPC).GetEvent(eventName); + + Assert.NotNull(eventInfo); + Assert.Equal(typeof(Action<>).MakeGenericType(argumentType), eventInfo!.EventHandlerType); + } +} + +internal static class NPCAwarenessApiCompileFixture +{ + internal static void SubscribeAndUnsubscribe(NPC npc) + { + Action playerHandler = _ => { }; + Action noiseHandler = _ => { }; + Action vehicleHandler = _ => { }; + + npc.OnNoticedDrugDealing += playerHandler; + npc.OnNoticedGeneralCrime += playerHandler; + npc.OnNoticedPettyCrime += playerHandler; + npc.OnNoticedPlayerViolatingCurfew += playerHandler; + npc.OnNoticedSuspiciousPlayer += playerHandler; + npc.OnGunshotHeard += noiseHandler; + npc.OnExplosionHeard += noiseHandler; + npc.OnHitByCar += vehicleHandler; + + npc.OnNoticedDrugDealing -= playerHandler; + npc.OnNoticedGeneralCrime -= playerHandler; + npc.OnNoticedPettyCrime -= playerHandler; + npc.OnNoticedPlayerViolatingCurfew -= playerHandler; + npc.OnNoticedSuspiciousPlayer -= playerHandler; + npc.OnGunshotHeard -= noiseHandler; + npc.OnExplosionHeard -= noiseHandler; + npc.OnHitByCar -= vehicleHandler; + } +} 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..14afd84a 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,35 +2989,149 @@ 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 { } + /// + /// Called when this NPC hears an explosion. The snapshot is null only when the native event has no noise event. + /// + public event Action OnExplosionHeard + { + add => AddAwarenessHandler( + value, + _explosionHeardRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onExplosionHeard, + ResolveNoiseEvent, + nameof(OnExplosionHeard)); + remove => RemoveAwarenessHandler( + value, + _explosionHeardRegistrations, + nameof(OnExplosionHeard)); + } - // TODO: Add OnGunshotHeard listener (currently missing NoiseEvent abstraction) - // public event Action OnGunshotHeard { } + /// + /// Called when this NPC hears a gunshot. The snapshot is null only when the native event has no noise event. + /// + public event Action OnGunshotHeard + { + add => AddAwarenessHandler( + value, + _gunshotHeardRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onGunshotHeard, + ResolveNoiseEvent, + nameof(OnGunshotHeard)); + remove => RemoveAwarenessHandler( + value, + _gunshotHeardRegistrations, + nameof(OnGunshotHeard)); + } - // TODO: Add OnHitByCar listener (currently missing LandVehicle abstraction) - // public event Action OnHitByCar { } + /// + /// Called when this NPC is hit by a vehicle. The vehicle is null when the native event has no vehicle. + /// + public event Action OnHitByCar + { + add => AddAwarenessHandler( + value, + _hitByCarRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onHitByCar, + ResolveVehicle, + nameof(OnHitByCar)); + remove => RemoveAwarenessHandler( + value, + _hitByCarRegistrations, + nameof(OnHitByCar)); + } - // TODO: Add OnNoticedDrugDealing listener (currently missing Player abstraction) - // public event Action OnNoticedDrugDealing { } + /// + /// Called when this NPC notices a player dealing drugs. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedDrugDealing + { + add => AddAwarenessHandler( + value, + _noticedDrugDealingRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedDrugDealing, + ResolvePlayer, + nameof(OnNoticedDrugDealing)); + remove => RemoveAwarenessHandler( + value, + _noticedDrugDealingRegistrations, + nameof(OnNoticedDrugDealing)); + } - // TODO: Add OnNoticedGeneralCrime listener (currently missing Player abstraction) - // public event Action OnNoticedGeneralCrime { } + /// + /// Called when this NPC notices a player committing a general crime. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedGeneralCrime + { + add => AddAwarenessHandler( + value, + _noticedGeneralCrimeRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedGeneralCrime, + ResolvePlayer, + nameof(OnNoticedGeneralCrime)); + remove => RemoveAwarenessHandler( + value, + _noticedGeneralCrimeRegistrations, + nameof(OnNoticedGeneralCrime)); + } - // TODO: Add OnNoticedPettyCrime listener (currently missing Player abstraction) - // public event Action OnNoticedPettyCrime { } + /// + /// Called when this NPC notices a player committing a petty crime. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedPettyCrime + { + add => AddAwarenessHandler( + value, + _noticedPettyCrimeRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedPettyCrime, + ResolvePlayer, + nameof(OnNoticedPettyCrime)); + remove => RemoveAwarenessHandler( + value, + _noticedPettyCrimeRegistrations, + nameof(OnNoticedPettyCrime)); + } - // TODO: Add OnPlayerViolatingCurfew listener (currently missing Player abstraction) - // public event Action OnPlayerViolatingCurfew { } + /// + /// Called when this NPC notices a player violating curfew. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedPlayerViolatingCurfew + { + add => AddAwarenessHandler( + value, + _noticedPlayerViolatingCurfewRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedPlayerViolatingCurfew, + ResolvePlayer, + nameof(OnNoticedPlayerViolatingCurfew)); + remove => RemoveAwarenessHandler( + value, + _noticedPlayerViolatingCurfewRegistrations, + nameof(OnNoticedPlayerViolatingCurfew)); + } - // TODO: Add OnNoticedSuspiciousPlayer listener (currently missing Player abstraction) - // public event Action OnNoticedSuspiciousPlayer { } + /// + /// Called when this NPC notices a suspicious player. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedSuspiciousPlayer + { + add => AddAwarenessHandler( + value, + _noticedSuspiciousPlayerRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedSuspiciousPlayer, + ResolvePlayer, + nameof(OnNoticedSuspiciousPlayer)); + remove => RemoveAwarenessHandler( + value, + _noticedSuspiciousPlayerRegistrations, + nameof(OnNoticedSuspiciousPlayer)); + } /// /// Called when the NPC died. @@ -3439,6 +3593,7 @@ private void InitializeAwarenessComponent() { awareness.Responses = validCivilianResponses; } + } private void InitializeBehaviourComponents() @@ -4142,10 +4297,28 @@ private void RestoreRuntimeAvatarAppearance() private NPCSupplier? _supplier; private NPCRelationship? _relationship; private NPCMessaging? _messaging; + private ManagedEventRegistrationTracker>? + _noticedDrugDealingRegistrations; + private ManagedEventRegistrationTracker>? + _noticedGeneralCrimeRegistrations; + private ManagedEventRegistrationTracker>? + _noticedPettyCrimeRegistrations; + private ManagedEventRegistrationTracker>? + _noticedPlayerViolatingCurfewRegistrations; + private ManagedEventRegistrationTracker>? + _noticedSuspiciousPlayerRegistrations; + private ManagedEventRegistrationTracker>? + _gunshotHeardRegistrations; + private ManagedEventRegistrationTracker>? + _explosionHeardRegistrations; + private ManagedEventRegistrationTracker>? + _hitByCarRegistrations; private NPCSmoking? _smoking; private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; + private ManagedEventRegistrationTracker? _enterVehicleRegistrations; + private ManagedEventRegistrationTracker? _exitVehicleRegistrations; private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4681,10 +4854,325 @@ private void ClearDealerRecommendationHooks() _recommendationSubscriptions.Clear(); } + private void AddAwarenessHandler( + Action? handler, + ManagedEventRegistrationTracker> registrations, + Func?> selectEvent, + Func convert, + string eventName) + { + if (handler == null) + return; + + S1NPCs.NPCAwareness? awareness = S1NPC?.Awareness; + UnityEvent? nativeEvent = awareness == null + ? null + : selectEvent(awareness); + if (nativeEvent == null) + return; + + try + { + Action managedHandler = value => + { + try + { + handler(convert(value)); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC.{eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + } + }; + +#if IL2CPPMELON + UnityAction nativeHandler = + DelegateSupport.ConvertDelegate>(managedHandler) + ?? throw new InvalidOperationException( + $"Could not create the native {eventName} listener."); +#else + UnityAction nativeHandler = new UnityAction(managedHandler); +#endif + nativeEvent.AddListener(nativeHandler); + registrations.Add( + handler, + new AwarenessEventRegistration(nativeEvent, nativeHandler)); + } + catch (Exception ex) + { + Logger.Warning( + $"Could not subscribe to NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + + private void RemoveAwarenessHandler( + Action? handler, + ManagedEventRegistrationTracker>? registrations, + string eventName) + { + if (handler == null || registrations == null || + !registrations.TryTakeLast(handler, out var registration)) + return; + + try + { + registration.Event.RemoveListener(registration.Handler); + } + catch (Exception ex) + { + registrations.Add(handler, registration); + Logger.Warning( + $"Could not unsubscribe from NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + + private void CleanupAwarenessEventHooks() + { + CleanupAwarenessHandlers( + _noticedDrugDealingRegistrations, + nameof(OnNoticedDrugDealing)); + CleanupAwarenessHandlers( + _noticedGeneralCrimeRegistrations, + nameof(OnNoticedGeneralCrime)); + CleanupAwarenessHandlers( + _noticedPettyCrimeRegistrations, + nameof(OnNoticedPettyCrime)); + CleanupAwarenessHandlers( + _noticedPlayerViolatingCurfewRegistrations, + nameof(OnNoticedPlayerViolatingCurfew)); + CleanupAwarenessHandlers( + _noticedSuspiciousPlayerRegistrations, + nameof(OnNoticedSuspiciousPlayer)); + CleanupAwarenessHandlers( + _gunshotHeardRegistrations, + nameof(OnGunshotHeard)); + CleanupAwarenessHandlers( + _explosionHeardRegistrations, + nameof(OnExplosionHeard)); + CleanupAwarenessHandlers( + _hitByCarRegistrations, + nameof(OnHitByCar)); + } + + private void CleanupAwarenessHandlers( + ManagedEventRegistrationTracker>? registrations, + string eventName) + { + if (registrations == null) + return; + + foreach (var registration in registrations.TakeAll()) + { + try + { + registration.NativeHandler.Event.RemoveListener( + registration.NativeHandler.Handler); + } + catch (Exception ex) + { + registrations.Add( + registration.ManagedHandler, + registration.NativeHandler); + Logger.Warning( + $"Could not clean up NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + } + + private static Player? ResolvePlayer(S1PlayerScripts.Player player) => + player == null + ? null + : Player.All.FirstOrDefault(apiPlayer => apiPlayer.S1Player == player); + + private static NPCNoiseEvent? ResolveNoiseEvent(S1Noise.NoiseEvent noiseEvent) => + noiseEvent == null + ? null + : new NPCNoiseEvent(noiseEvent); + + private static LandVehicle? ResolveVehicle(S1Vehicles.LandVehicle vehicle) => + vehicle == null + ? null + : new LandVehicle(vehicle); + + private sealed class AwarenessEventRegistration + { + internal UnityEvent Event { get; } + internal UnityAction Handler { get; } + + internal AwarenessEventRegistration( + UnityEvent nativeEvent, + UnityAction handler) + { + Event = nativeEvent; + Handler = handler; + } + } + internal void CleanupRuntimeHooks() { ClearDealerRecommendationHooks(); + CleanupAwarenessEventHooks(); _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/Entities/NPCNoiseEvent.cs b/S1API/Entities/NPCNoiseEvent.cs new file mode 100644 index 00000000..d39e8430 --- /dev/null +++ b/S1API/Entities/NPCNoiseEvent.cs @@ -0,0 +1,86 @@ +#if IL2CPPMELON +using S1Noise = Il2CppScheduleOne.Noise; +#elif MONOMELON +using S1Noise = ScheduleOne.Noise; +#endif + +using UnityEngine; + +namespace S1API.Entities +{ + /// + /// Describes the type of noise an NPC heard. + /// + public enum NPCNoiseType + { + /// + /// A footstep sound. + /// + Footstep = 0, + + /// + /// A gunshot sound. + /// + Gunshot = 1, + + /// + /// An explosion sound. + /// + Explosion = 2 + } + + /// + /// An immutable snapshot of a noise event heard by an NPC. + /// + public sealed class NPCNoiseEvent + { + /// + /// The world-space origin of the noise. + /// + public Vector3 Origin { get; } + + /// + /// The range of the noise. + /// + public float Range { get; } + + /// + /// The type of noise. + /// + public NPCNoiseType Type { get; } + + /// + /// The GameObject that emitted the noise, if the native event identified one. + /// + public GameObject? Source { get; } + + /// + /// Whether the noise originated in the sewer. + /// + public bool OriginInSewer { get; } + + internal NPCNoiseEvent(S1Noise.NoiseEvent noiseEvent) + : this( + noiseEvent.origin, + noiseEvent.range, + (NPCNoiseType)(int)noiseEvent.type, + noiseEvent.source, + noiseEvent.OriginInSewer) + { + } + + internal NPCNoiseEvent( + Vector3 origin, + float range, + NPCNoiseType type, + GameObject? source, + bool originInSewer) + { + Origin = origin; + Range = range; + Type = type; + Source = source; + OriginInSewer = originInSewer; + } + } +} 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; + } } }