From 0c64e5b686cb510a5f459ca526fc0f71a47d4b4d Mon Sep 17 00:00:00 2001 From: "Diffuin[bot]" Date: Thu, 13 Aug 2026 01:59:22 +0000 Subject: [PATCH 1/7] chore(diffuin): address #244 --- S1API.Tests/Entities/NPCAwarenessApiTests.cs | 86 ++++ S1API/Entities/NPC.cs | 391 ++++++++++++++++++- S1API/Entities/NPCNoiseEvent.cs | 86 ++++ 3 files changed, 547 insertions(+), 16 deletions(-) create mode 100644 S1API.Tests/Entities/NPCAwarenessApiTests.cs create mode 100644 S1API/Entities/NPCNoiseEvent.cs diff --git a/S1API.Tests/Entities/NPCAwarenessApiTests.cs b/S1API.Tests/Entities/NPCAwarenessApiTests.cs new file mode 100644 index 00000000..e829a04a --- /dev/null +++ b/S1API.Tests/Entities/NPCAwarenessApiTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Reflection; +using S1API.Entities; +using S1API.Vehicles; +using UnityEngine; + +namespace S1API.Tests.Entities; + +public sealed class NPCAwarenessApiTests +{ + [Fact] + public void NoiseSnapshotCapturesReadOnlyManagedValues() + { + var origin = new Vector3(1f, 2f, 3f); + var snapshot = new NPCNoiseEvent( + origin, + 24f, + NPCNoiseType.Gunshot, + source: null, + originInSewer: true); + + Assert.Equal(origin, snapshot.Origin); + Assert.Equal(24f, snapshot.Range); + Assert.Equal(NPCNoiseType.Gunshot, snapshot.Type); + Assert.Null(snapshot.Source); + Assert.True(snapshot.OriginInSewer); + Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.Origin))!.CanWrite); + Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.Range))!.CanWrite); + Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.Type))!.CanWrite); + Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.Source))!.CanWrite); + Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.OriginInSewer))!.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/Entities/NPC.cs b/S1API/Entities/NPC.cs index ff8bbc5e..1dbefabd 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -2955,29 +2955,77 @@ public void ClearConversationCategories() // 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(ref _explosionHeardHandlers, value); + remove => RemoveAwarenessHandler(ref _explosionHeardHandlers, value); + } - // 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(ref _gunshotHeardHandlers, value); + remove => RemoveAwarenessHandler(ref _gunshotHeardHandlers, value); + } - // 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(ref _hitByCarHandlers, value); + remove => RemoveAwarenessHandler(ref _hitByCarHandlers, value); + } - // 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(ref _noticedDrugDealingHandlers, value); + remove => RemoveAwarenessHandler(ref _noticedDrugDealingHandlers, value); + } - // 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(ref _noticedGeneralCrimeHandlers, value); + remove => RemoveAwarenessHandler(ref _noticedGeneralCrimeHandlers, value); + } - // 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(ref _noticedPettyCrimeHandlers, value); + remove => RemoveAwarenessHandler(ref _noticedPettyCrimeHandlers, value); + } - // 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(ref _noticedPlayerViolatingCurfewHandlers, value); + remove => RemoveAwarenessHandler(ref _noticedPlayerViolatingCurfewHandlers, value); + } - // 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(ref _noticedSuspiciousPlayerHandlers, value); + remove => RemoveAwarenessHandler(ref _noticedSuspiciousPlayerHandlers, value); + } /// /// Called when the NPC died. @@ -3439,6 +3487,8 @@ private void InitializeAwarenessComponent() { awareness.Responses = validCivilianResponses; } + + EnsureAwarenessEventHooks(); } private void InitializeBehaviourComponents() @@ -4142,6 +4192,23 @@ private void RestoreRuntimeAvatarAppearance() private NPCSupplier? _supplier; private NPCRelationship? _relationship; private NPCMessaging? _messaging; + private Action? _noticedDrugDealingHandlers; + private Action? _noticedGeneralCrimeHandlers; + private Action? _noticedPettyCrimeHandlers; + private Action? _noticedPlayerViolatingCurfewHandlers; + private Action? _noticedSuspiciousPlayerHandlers; + private Action? _gunshotHeardHandlers; + private Action? _explosionHeardHandlers; + private Action? _hitByCarHandlers; + private S1NPCs.NPCAwareness? _subscribedAwareness; + private Action? _nativeNoticedDrugDealingDispatcher; + private Action? _nativeNoticedGeneralCrimeDispatcher; + private Action? _nativeNoticedPettyCrimeDispatcher; + private Action? _nativeNoticedPlayerViolatingCurfewDispatcher; + private Action? _nativeNoticedSuspiciousPlayerDispatcher; + private Action? _nativeGunshotHeardDispatcher; + private Action? _nativeExplosionHeardDispatcher; + private Action? _nativeHitByCarDispatcher; private NPCSmoking? _smoking; private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; @@ -4681,9 +4748,301 @@ private void ClearDealerRecommendationHooks() _recommendationSubscriptions.Clear(); } + private void AddAwarenessHandler( + ref Action? handlers, + Action? handler) + { + if (handler == null) + return; + + handlers += handler; + EnsureAwarenessEventHooks(); + } + + private void RemoveAwarenessHandler( + ref Action? handlers, + Action? handler) + { + if (handler == null) + return; + + handlers -= handler; + RemoveAwarenessEventHooksWhenUnused(); + } + + private void EnsureAwarenessEventHooks() + { + if (!HasAwarenessEventHandlers()) + return; + + S1NPCs.NPCAwareness? awareness = S1NPC?.Awareness; + if (awareness == null) + return; + + if (!ReferenceEquals(awareness, _subscribedAwareness)) + { + RemoveAwarenessEventHooks(); + _subscribedAwareness = awareness; + } + + try + { + if (_noticedDrugDealingHandlers != null) + { + global::S1API.Utils.EventHelper.AddListener( + GetOrCreateNoticedDrugDealingDispatcher(), + awareness.onNoticedDrugDealing); + } + + if (_noticedGeneralCrimeHandlers != null) + { + global::S1API.Utils.EventHelper.AddListener( + GetOrCreateNoticedGeneralCrimeDispatcher(), + awareness.onNoticedGeneralCrime); + } + + if (_noticedPettyCrimeHandlers != null) + { + global::S1API.Utils.EventHelper.AddListener( + GetOrCreateNoticedPettyCrimeDispatcher(), + awareness.onNoticedPettyCrime); + } + + if (_noticedPlayerViolatingCurfewHandlers != null) + { + global::S1API.Utils.EventHelper.AddListener( + GetOrCreateNoticedPlayerViolatingCurfewDispatcher(), + awareness.onNoticedPlayerViolatingCurfew); + } + + if (_noticedSuspiciousPlayerHandlers != null) + { + global::S1API.Utils.EventHelper.AddListener( + GetOrCreateNoticedSuspiciousPlayerDispatcher(), + awareness.onNoticedSuspiciousPlayer); + } + + if (_gunshotHeardHandlers != null) + { + global::S1API.Utils.EventHelper.AddListener( + GetOrCreateGunshotHeardDispatcher(), + awareness.onGunshotHeard); + } + + if (_explosionHeardHandlers != null) + { + global::S1API.Utils.EventHelper.AddListener( + GetOrCreateExplosionHeardDispatcher(), + awareness.onExplosionHeard); + } + + if (_hitByCarHandlers != null) + { + global::S1API.Utils.EventHelper.AddListener( + GetOrCreateHitByCarDispatcher(), + awareness.onHitByCar); + } + } + catch (Exception ex) + { + Logger.Warning($"Could not attach NPC awareness event hooks: {ex}"); + } + } + + private void RemoveAwarenessEventHooksWhenUnused() + { + if (!HasAwarenessEventHandlers()) + RemoveAwarenessEventHooks(); + } + + private bool HasAwarenessEventHandlers() => + _noticedDrugDealingHandlers != null || + _noticedGeneralCrimeHandlers != null || + _noticedPettyCrimeHandlers != null || + _noticedPlayerViolatingCurfewHandlers != null || + _noticedSuspiciousPlayerHandlers != null || + _gunshotHeardHandlers != null || + _explosionHeardHandlers != null || + _hitByCarHandlers != null; + + private void RemoveAwarenessEventHooks() + { + S1NPCs.NPCAwareness? awareness = _subscribedAwareness; + if (awareness == null) + return; + + try + { + if (_nativeNoticedDrugDealingDispatcher != null) + { + global::S1API.Utils.EventHelper.RemoveListener( + _nativeNoticedDrugDealingDispatcher, + awareness.onNoticedDrugDealing); + } + + if (_nativeNoticedGeneralCrimeDispatcher != null) + { + global::S1API.Utils.EventHelper.RemoveListener( + _nativeNoticedGeneralCrimeDispatcher, + awareness.onNoticedGeneralCrime); + } + + if (_nativeNoticedPettyCrimeDispatcher != null) + { + global::S1API.Utils.EventHelper.RemoveListener( + _nativeNoticedPettyCrimeDispatcher, + awareness.onNoticedPettyCrime); + } + + if (_nativeNoticedPlayerViolatingCurfewDispatcher != null) + { + global::S1API.Utils.EventHelper.RemoveListener( + _nativeNoticedPlayerViolatingCurfewDispatcher, + awareness.onNoticedPlayerViolatingCurfew); + } + + if (_nativeNoticedSuspiciousPlayerDispatcher != null) + { + global::S1API.Utils.EventHelper.RemoveListener( + _nativeNoticedSuspiciousPlayerDispatcher, + awareness.onNoticedSuspiciousPlayer); + } + + if (_nativeGunshotHeardDispatcher != null) + { + global::S1API.Utils.EventHelper.RemoveListener( + _nativeGunshotHeardDispatcher, + awareness.onGunshotHeard); + } + + if (_nativeExplosionHeardDispatcher != null) + { + global::S1API.Utils.EventHelper.RemoveListener( + _nativeExplosionHeardDispatcher, + awareness.onExplosionHeard); + } + + if (_nativeHitByCarDispatcher != null) + { + global::S1API.Utils.EventHelper.RemoveListener( + _nativeHitByCarDispatcher, + awareness.onHitByCar); + } + } + catch (Exception ex) + { + Logger.Warning($"Could not remove NPC awareness event hooks: {ex}"); + } + finally + { + _subscribedAwareness = null; + } + } + + private Action GetOrCreateNoticedDrugDealingDispatcher() => + _nativeNoticedDrugDealingDispatcher ??= DispatchNoticedDrugDealing; + + private Action GetOrCreateNoticedGeneralCrimeDispatcher() => + _nativeNoticedGeneralCrimeDispatcher ??= DispatchNoticedGeneralCrime; + + private Action GetOrCreateNoticedPettyCrimeDispatcher() => + _nativeNoticedPettyCrimeDispatcher ??= DispatchNoticedPettyCrime; + + private Action GetOrCreateNoticedPlayerViolatingCurfewDispatcher() => + _nativeNoticedPlayerViolatingCurfewDispatcher ??= DispatchNoticedPlayerViolatingCurfew; + + private Action GetOrCreateNoticedSuspiciousPlayerDispatcher() => + _nativeNoticedSuspiciousPlayerDispatcher ??= DispatchNoticedSuspiciousPlayer; + + private Action GetOrCreateGunshotHeardDispatcher() => + _nativeGunshotHeardDispatcher ??= DispatchGunshotHeard; + + private Action GetOrCreateExplosionHeardDispatcher() => + _nativeExplosionHeardDispatcher ??= DispatchExplosionHeard; + + private Action GetOrCreateHitByCarDispatcher() => + _nativeHitByCarDispatcher ??= DispatchHitByCar; + + private void DispatchNoticedDrugDealing(S1PlayerScripts.Player player) => + InvokeAwarenessHandlers( + _noticedDrugDealingHandlers, + ResolvePlayer(player), + nameof(OnNoticedDrugDealing)); + + private void DispatchNoticedGeneralCrime(S1PlayerScripts.Player player) => + InvokeAwarenessHandlers( + _noticedGeneralCrimeHandlers, + ResolvePlayer(player), + nameof(OnNoticedGeneralCrime)); + + private void DispatchNoticedPettyCrime(S1PlayerScripts.Player player) => + InvokeAwarenessHandlers( + _noticedPettyCrimeHandlers, + ResolvePlayer(player), + nameof(OnNoticedPettyCrime)); + + private void DispatchNoticedPlayerViolatingCurfew(S1PlayerScripts.Player player) => + InvokeAwarenessHandlers( + _noticedPlayerViolatingCurfewHandlers, + ResolvePlayer(player), + nameof(OnNoticedPlayerViolatingCurfew)); + + private void DispatchNoticedSuspiciousPlayer(S1PlayerScripts.Player player) => + InvokeAwarenessHandlers( + _noticedSuspiciousPlayerHandlers, + ResolvePlayer(player), + nameof(OnNoticedSuspiciousPlayer)); + + private void DispatchGunshotHeard(S1Noise.NoiseEvent noiseEvent) => + InvokeAwarenessHandlers( + _gunshotHeardHandlers, + noiseEvent == null ? null : new NPCNoiseEvent(noiseEvent), + nameof(OnGunshotHeard)); + + private void DispatchExplosionHeard(S1Noise.NoiseEvent noiseEvent) => + InvokeAwarenessHandlers( + _explosionHeardHandlers, + noiseEvent == null ? null : new NPCNoiseEvent(noiseEvent), + nameof(OnExplosionHeard)); + + private void DispatchHitByCar(S1Vehicles.LandVehicle vehicle) => + InvokeAwarenessHandlers( + _hitByCarHandlers, + vehicle == null ? null : new LandVehicle(vehicle), + nameof(OnHitByCar)); + + private static Player? ResolvePlayer(S1PlayerScripts.Player player) => + player == null + ? null + : Player.All.FirstOrDefault(apiPlayer => apiPlayer.S1Player == player); + + private static void InvokeAwarenessHandlers( + Action? handlers, + T value, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(value); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC {eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + } + } + } + internal void CleanupRuntimeHooks() { ClearDealerRecommendationHooks(); + RemoveAwarenessEventHooks(); _messaging?.Cleanup(); } 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; + } + } +} From c3d31e4652bd208e2c8aa81c4345cf1adf986c27 Mon Sep 17 00:00:00 2001 From: "Diffuin[bot]" Date: Thu, 13 Aug 2026 02:26:38 +0000 Subject: [PATCH 2/7] chore(diffuin): address #243 --- S1API/Entities/NPC.cs | 222 +++++++++++++++++- S1API/Internal/Lifecycle/SceneStateCleaner.cs | 10 +- 2 files changed, 223 insertions(+), 9 deletions(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index ff8bbc5e..d3122ed4 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,54 @@ 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 + { + if (value == null) + return; + + _enterVehicleHandlers += value; + EnsureVehicleLifecycleHooks(); + } + remove + { + if (value == null) + return; + + _enterVehicleHandlers -= value; + RemoveVehicleLifecycleHooksWhenUnused(); + } + } + + /// + /// Occurs when the NPC exits a vehicle. + /// + /// This event preserves the timing of the native vehicle-exit callback. + public event Action OnExitVehicle + { + add + { + if (value == null) + return; + + _exitVehicleHandlers += value; + EnsureVehicleLifecycleHooks(); + } + remove + { + if (value == null) + return; + + _exitVehicleHandlers -= value; + RemoveVehicleLifecycleHooksWhenUnused(); + } + } + // TODO: Add Inventory (currently missing NPCInventory abstraction) // public ??? Inventory { get; set; } @@ -2949,12 +2999,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 +4190,11 @@ private void RestoreRuntimeAvatarAppearance() private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; + private Action? _enterVehicleHandlers; + private Action? _exitVehicleHandlers; + private NativeVehicleLifecycleAction? _nativeEnterVehicleDispatcher; + private NativeVehicleLifecycleAction? _nativeExitVehicleDispatcher; + private bool _vehicleLifecycleHooksSubscribed; private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4685,6 +4734,167 @@ internal void CleanupRuntimeHooks() { ClearDealerRecommendationHooks(); _messaging?.Cleanup(); + CleanupVehicleLifecycleHooks(); + } + + private void EnsureVehicleLifecycleHooks() + { + if (_vehicleLifecycleHooksSubscribed || + (_enterVehicleHandlers == null && _exitVehicleHandlers == null)) + { + return; + } + + NativeVehicleLifecycleAction enterDispatcher = + GetOrCreateNativeEnterVehicleDispatcher(); + NativeVehicleLifecycleAction exitDispatcher = + GetOrCreateNativeExitVehicleDispatcher(); + + try + { + _vehicleLifecycleHooksSubscribed = true; +#if IL2CPPMELON + S1NPC.onEnterVehicle = S1NPC.onEnterVehicle == null + ? enterDispatcher + : Il2CppSystem.Delegate.Combine( + S1NPC.onEnterVehicle, + enterDispatcher) + .Cast(); + S1NPC.onExitVehicle = S1NPC.onExitVehicle == null + ? exitDispatcher + : Il2CppSystem.Delegate.Combine( + S1NPC.onExitVehicle, + exitDispatcher) + .Cast(); +#else + S1NPC.onEnterVehicle += enterDispatcher; + S1NPC.onExitVehicle += exitDispatcher; +#endif + } + catch (Exception ex) + { + RemoveVehicleLifecycleHooks(); + Logger.Warning( + $"Could not attach native vehicle lifecycle hooks for '{GetSafeNpcId()}': {ex}"); + } + } + + private NativeVehicleLifecycleAction GetOrCreateNativeEnterVehicleDispatcher() + { + if (_nativeEnterVehicleDispatcher != null) + return _nativeEnterVehicleDispatcher; + +#if IL2CPPMELON + _nativeEnterVehicleDispatcher = + DelegateSupport.ConvertDelegate( + new Action(DispatchEnterVehicle)) + ?? throw new InvalidOperationException( + "Could not create the native vehicle-entry dispatcher."); +#else + _nativeEnterVehicleDispatcher = DispatchEnterVehicle; +#endif + return _nativeEnterVehicleDispatcher; + } + + private NativeVehicleLifecycleAction GetOrCreateNativeExitVehicleDispatcher() + { + if (_nativeExitVehicleDispatcher != null) + return _nativeExitVehicleDispatcher; + +#if IL2CPPMELON + _nativeExitVehicleDispatcher = + DelegateSupport.ConvertDelegate( + new Action(DispatchExitVehicle)) + ?? throw new InvalidOperationException( + "Could not create the native vehicle-exit dispatcher."); +#else + _nativeExitVehicleDispatcher = DispatchExitVehicle; +#endif + return _nativeExitVehicleDispatcher; + } + + private void RemoveVehicleLifecycleHooksWhenUnused() + { + if (_enterVehicleHandlers == null && _exitVehicleHandlers == null) + RemoveVehicleLifecycleHooks(); + } + + private void CleanupVehicleLifecycleHooks() + { + RemoveVehicleLifecycleHooks(); + _enterVehicleHandlers = null; + _exitVehicleHandlers = null; + _nativeEnterVehicleDispatcher = null; + _nativeExitVehicleDispatcher = null; + } + + private void RemoveVehicleLifecycleHooks() + { + if (!_vehicleLifecycleHooksSubscribed || + _nativeEnterVehicleDispatcher == null || + _nativeExitVehicleDispatcher == null) + { + _vehicleLifecycleHooksSubscribed = false; + return; + } + + try + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remainingEnter = Il2CppSystem.Delegate.Remove( + S1NPC.onEnterVehicle, + _nativeEnterVehicleDispatcher); + S1NPC.onEnterVehicle = + remainingEnter?.Cast(); + Il2CppSystem.Delegate? remainingExit = Il2CppSystem.Delegate.Remove( + S1NPC.onExitVehicle, + _nativeExitVehicleDispatcher); + S1NPC.onExitVehicle = + remainingExit?.Cast(); +#else + S1NPC.onEnterVehicle -= _nativeEnterVehicleDispatcher; + S1NPC.onExitVehicle -= _nativeExitVehicleDispatcher; +#endif + } + catch (Exception ex) + { + Logger.Warning( + $"Could not remove native vehicle lifecycle hooks for '{GetSafeNpcId()}': {ex}"); + } + finally + { + _vehicleLifecycleHooksSubscribed = false; + } + } + + private void DispatchEnterVehicle(S1Vehicles.LandVehicle vehicle) => + DispatchVehicleLifecycleEvent(_enterVehicleHandlers, vehicle, "OnEnterVehicle"); + + private void DispatchExitVehicle(S1Vehicles.LandVehicle vehicle) => + DispatchVehicleLifecycleEvent(_exitVehicleHandlers, vehicle, "OnExitVehicle"); + + private void DispatchVehicleLifecycleEvent( + Action? handlers, + S1Vehicles.LandVehicle vehicle, + string eventName) + { + if (handlers == null) + return; + + var wrappedVehicle = new LandVehicle(vehicle); + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(wrappedVehicle); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC.{eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {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) } } } - From 3338967822baf27f002a66cff0eac51dcffa3fa5 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 17:30:07 -0700 Subject: [PATCH 3/7] refactor(Entities): simplify NPC vehicle event bridges --- .../Entities/NPCVehicleLifecycleApiTests.cs | 33 +++ .../ManagedEventRegistrationTrackerTests.cs | 24 ++ S1API/Entities/NPC.cs | 266 ++++++++---------- .../Utils/ManagedEventRegistrationTracker.cs | 21 +- 4 files changed, 198 insertions(+), 146 deletions(-) create mode 100644 S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs 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 d3122ed4..2e7c33ef 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -2825,22 +2825,16 @@ public Map.Building? CurrentBuilding /// This event preserves the timing of the native vehicle-entry callback. public event Action OnEnterVehicle { - add - { - if (value == null) - return; - - _enterVehicleHandlers += value; - EnsureVehicleLifecycleHooks(); - } - remove - { - if (value == null) - return; - - _enterVehicleHandlers -= value; - RemoveVehicleLifecycleHooksWhenUnused(); - } + add => AddVehicleLifecycleHandler( + value, + _enterVehicleRegistrations, + SubscribeEnterVehicle, + nameof(OnEnterVehicle)); + remove => RemoveVehicleLifecycleHandler( + value, + _enterVehicleRegistrations, + UnsubscribeEnterVehicle, + nameof(OnEnterVehicle)); } /// @@ -2849,22 +2843,16 @@ public event Action OnEnterVehicle /// This event preserves the timing of the native vehicle-exit callback. public event Action OnExitVehicle { - add - { - if (value == null) - return; - - _exitVehicleHandlers += value; - EnsureVehicleLifecycleHooks(); - } - remove - { - if (value == null) - return; - - _exitVehicleHandlers -= value; - RemoveVehicleLifecycleHooksWhenUnused(); - } + add => AddVehicleLifecycleHandler( + value, + _exitVehicleRegistrations, + SubscribeExitVehicle, + nameof(OnExitVehicle)); + remove => RemoveVehicleLifecycleHandler( + value, + _exitVehicleRegistrations, + UnsubscribeExitVehicle, + nameof(OnExitVehicle)); } // TODO: Add Inventory (currently missing NPCInventory abstraction) @@ -4190,11 +4178,10 @@ private void RestoreRuntimeAvatarAppearance() private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; - private Action? _enterVehicleHandlers; - private Action? _exitVehicleHandlers; - private NativeVehicleLifecycleAction? _nativeEnterVehicleDispatcher; - private NativeVehicleLifecycleAction? _nativeExitVehicleDispatcher; - private bool _vehicleLifecycleHooksSubscribed; + private readonly ManagedEventRegistrationTracker _enterVehicleRegistrations = + new ManagedEventRegistrationTracker(); + private readonly ManagedEventRegistrationTracker _exitVehicleRegistrations = + new ManagedEventRegistrationTracker(); private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4737,162 +4724,155 @@ internal void CleanupRuntimeHooks() CleanupVehicleLifecycleHooks(); } - private void EnsureVehicleLifecycleHooks() + private void AddVehicleLifecycleHandler( + Action? handler, + ManagedEventRegistrationTracker registrations, + Action subscribe, + string eventName) { - if (_vehicleLifecycleHooksSubscribed || - (_enterVehicleHandlers == null && _exitVehicleHandlers == null)) - { + 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}"); + } + } - NativeVehicleLifecycleAction enterDispatcher = - GetOrCreateNativeEnterVehicleDispatcher(); - NativeVehicleLifecycleAction exitDispatcher = - GetOrCreateNativeExitVehicleDispatcher(); + private void RemoveVehicleLifecycleHandler( + Action? handler, + ManagedEventRegistrationTracker registrations, + Action unsubscribe, + string eventName) + { + if (handler == null || !registrations.TryTakeLast(handler, out var nativeHandler)) + return; try { - _vehicleLifecycleHooksSubscribed = true; -#if IL2CPPMELON - S1NPC.onEnterVehicle = S1NPC.onEnterVehicle == null - ? enterDispatcher - : Il2CppSystem.Delegate.Combine( - S1NPC.onEnterVehicle, - enterDispatcher) - .Cast(); - S1NPC.onExitVehicle = S1NPC.onExitVehicle == null - ? exitDispatcher - : Il2CppSystem.Delegate.Combine( - S1NPC.onExitVehicle, - exitDispatcher) - .Cast(); -#else - S1NPC.onEnterVehicle += enterDispatcher; - S1NPC.onExitVehicle += exitDispatcher; -#endif + unsubscribe(nativeHandler); } catch (Exception ex) { - RemoveVehicleLifecycleHooks(); + registrations.Add(handler, nativeHandler); Logger.Warning( - $"Could not attach native vehicle lifecycle hooks for '{GetSafeNpcId()}': {ex}"); + $"Could not unsubscribe from NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); } } - private NativeVehicleLifecycleAction GetOrCreateNativeEnterVehicleDispatcher() + private NativeVehicleLifecycleAction CreateVehicleLifecycleHandler( + Action handler, + string eventName) { - if (_nativeEnterVehicleDispatcher != null) - return _nativeEnterVehicleDispatcher; + 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 - _nativeEnterVehicleDispatcher = - DelegateSupport.ConvertDelegate( - new Action(DispatchEnterVehicle)) + return DelegateSupport.ConvertDelegate(managedHandler) ?? throw new InvalidOperationException( - "Could not create the native vehicle-entry dispatcher."); + $"Could not create the native {eventName} delegate."); #else - _nativeEnterVehicleDispatcher = DispatchEnterVehicle; + return managedHandler; #endif - return _nativeEnterVehicleDispatcher; } - private NativeVehicleLifecycleAction GetOrCreateNativeExitVehicleDispatcher() + private void SubscribeEnterVehicle(NativeVehicleLifecycleAction handler) { - if (_nativeExitVehicleDispatcher != null) - return _nativeExitVehicleDispatcher; - #if IL2CPPMELON - _nativeExitVehicleDispatcher = - DelegateSupport.ConvertDelegate( - new Action(DispatchExitVehicle)) - ?? throw new InvalidOperationException( - "Could not create the native vehicle-exit dispatcher."); + S1NPC.onEnterVehicle = S1NPC.onEnterVehicle == null + ? handler + : Il2CppSystem.Delegate.Combine(S1NPC.onEnterVehicle, handler) + .Cast(); #else - _nativeExitVehicleDispatcher = DispatchExitVehicle; + S1NPC.onEnterVehicle += handler; #endif - return _nativeExitVehicleDispatcher; } - private void RemoveVehicleLifecycleHooksWhenUnused() + private void UnsubscribeEnterVehicle(NativeVehicleLifecycleAction handler) { - if (_enterVehicleHandlers == null && _exitVehicleHandlers == null) - RemoveVehicleLifecycleHooks(); +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1NPC.onEnterVehicle, + handler); + S1NPC.onEnterVehicle = remaining?.Cast(); +#else + S1NPC.onEnterVehicle -= handler; +#endif } - private void CleanupVehicleLifecycleHooks() + private void SubscribeExitVehicle(NativeVehicleLifecycleAction handler) { - RemoveVehicleLifecycleHooks(); - _enterVehicleHandlers = null; - _exitVehicleHandlers = null; - _nativeEnterVehicleDispatcher = null; - _nativeExitVehicleDispatcher = null; +#if IL2CPPMELON + S1NPC.onExitVehicle = S1NPC.onExitVehicle == null + ? handler + : Il2CppSystem.Delegate.Combine(S1NPC.onExitVehicle, handler) + .Cast(); +#else + S1NPC.onExitVehicle += handler; +#endif } - private void RemoveVehicleLifecycleHooks() + private void UnsubscribeExitVehicle(NativeVehicleLifecycleAction handler) { - if (!_vehicleLifecycleHooksSubscribed || - _nativeEnterVehicleDispatcher == null || - _nativeExitVehicleDispatcher == null) - { - _vehicleLifecycleHooksSubscribed = false; - return; - } - - try - { #if IL2CPPMELON - Il2CppSystem.Delegate? remainingEnter = Il2CppSystem.Delegate.Remove( - S1NPC.onEnterVehicle, - _nativeEnterVehicleDispatcher); - S1NPC.onEnterVehicle = - remainingEnter?.Cast(); - Il2CppSystem.Delegate? remainingExit = Il2CppSystem.Delegate.Remove( - S1NPC.onExitVehicle, - _nativeExitVehicleDispatcher); - S1NPC.onExitVehicle = - remainingExit?.Cast(); + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1NPC.onExitVehicle, + handler); + S1NPC.onExitVehicle = remaining?.Cast(); #else - S1NPC.onEnterVehicle -= _nativeEnterVehicleDispatcher; - S1NPC.onExitVehicle -= _nativeExitVehicleDispatcher; + S1NPC.onExitVehicle -= handler; #endif - } - catch (Exception ex) - { - Logger.Warning( - $"Could not remove native vehicle lifecycle hooks for '{GetSafeNpcId()}': {ex}"); - } - finally - { - _vehicleLifecycleHooksSubscribed = false; - } } - private void DispatchEnterVehicle(S1Vehicles.LandVehicle vehicle) => - DispatchVehicleLifecycleEvent(_enterVehicleHandlers, vehicle, "OnEnterVehicle"); - - private void DispatchExitVehicle(S1Vehicles.LandVehicle vehicle) => - DispatchVehicleLifecycleEvent(_exitVehicleHandlers, vehicle, "OnExitVehicle"); + private void CleanupVehicleLifecycleHooks() + { + CleanupVehicleLifecycleHandlers( + _enterVehicleRegistrations, + UnsubscribeEnterVehicle, + nameof(OnEnterVehicle)); + CleanupVehicleLifecycleHandlers( + _exitVehicleRegistrations, + UnsubscribeExitVehicle, + nameof(OnExitVehicle)); + } - private void DispatchVehicleLifecycleEvent( - Action? handlers, - S1Vehicles.LandVehicle vehicle, + private void CleanupVehicleLifecycleHandlers( + ManagedEventRegistrationTracker registrations, + Action unsubscribe, string eventName) { - if (handlers == null) - return; - - var wrappedVehicle = new LandVehicle(vehicle); - foreach (Action handler in handlers.GetInvocationList()) + foreach (var registration in registrations.TakeAll()) { try { - handler(wrappedVehicle); + unsubscribe(registration.NativeHandler); } catch (Exception ex) { + registrations.Add( + registration.ManagedHandler, + registration.NativeHandler); Logger.Warning( - $"NPC.{eventName} subscriber " + - $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + $"Could not clean up NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); } } } 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; + } } } From c3e97719be8891f5bf214e3dc3ddd94a638abac3 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 17:36:10 -0700 Subject: [PATCH 4/7] fix(Entities): initialize vehicle event tracking lazily --- S1API/Entities/NPC.cs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 2e7c33ef..2116ee6d 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -2827,7 +2827,8 @@ public event Action OnEnterVehicle { add => AddVehicleLifecycleHandler( value, - _enterVehicleRegistrations, + _enterVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), SubscribeEnterVehicle, nameof(OnEnterVehicle)); remove => RemoveVehicleLifecycleHandler( @@ -2845,7 +2846,8 @@ public event Action OnExitVehicle { add => AddVehicleLifecycleHandler( value, - _exitVehicleRegistrations, + _exitVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), SubscribeExitVehicle, nameof(OnExitVehicle)); remove => RemoveVehicleLifecycleHandler( @@ -4178,10 +4180,8 @@ private void RestoreRuntimeAvatarAppearance() private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; - private readonly ManagedEventRegistrationTracker _enterVehicleRegistrations = - new ManagedEventRegistrationTracker(); - private readonly ManagedEventRegistrationTracker _exitVehicleRegistrations = - new ManagedEventRegistrationTracker(); + private ManagedEventRegistrationTracker? _enterVehicleRegistrations; + private ManagedEventRegistrationTracker? _exitVehicleRegistrations; private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4749,11 +4749,12 @@ private void AddVehicleLifecycleHandler( private void RemoveVehicleLifecycleHandler( Action? handler, - ManagedEventRegistrationTracker registrations, + ManagedEventRegistrationTracker? registrations, Action unsubscribe, string eventName) { - if (handler == null || !registrations.TryTakeLast(handler, out var nativeHandler)) + if (handler == null || registrations == null || + !registrations.TryTakeLast(handler, out var nativeHandler)) return; try @@ -4856,10 +4857,13 @@ private void CleanupVehicleLifecycleHooks() } private void CleanupVehicleLifecycleHandlers( - ManagedEventRegistrationTracker registrations, + ManagedEventRegistrationTracker? registrations, Action unsubscribe, string eventName) { + if (registrations == null) + return; + foreach (var registration in registrations.TakeAll()) { try From 636c5acc33af93c2b9ac6bf76ac0e18768531ce6 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 17:37:38 -0700 Subject: [PATCH 5/7] refactor(Entities): simplify NPC awareness event bridges --- S1API.Tests/Entities/NPCAwarenessApiTests.cs | 24 +- S1API/Entities/NPC.cs | 501 ++++++++----------- 2 files changed, 236 insertions(+), 289 deletions(-) diff --git a/S1API.Tests/Entities/NPCAwarenessApiTests.cs b/S1API.Tests/Entities/NPCAwarenessApiTests.cs index e829a04a..94c7dd10 100644 --- a/S1API.Tests/Entities/NPCAwarenessApiTests.cs +++ b/S1API.Tests/Entities/NPCAwarenessApiTests.cs @@ -8,8 +8,9 @@ namespace S1API.Tests.Entities; public sealed class NPCAwarenessApiTests { +#if MONOMELON [Fact] - public void NoiseSnapshotCapturesReadOnlyManagedValues() + public void NoiseSnapshotCapturesManagedValues() { var origin = new Vector3(1f, 2f, 3f); var snapshot = new NPCNoiseEvent( @@ -24,11 +25,22 @@ public void NoiseSnapshotCapturesReadOnlyManagedValues() Assert.Equal(NPCNoiseType.Gunshot, snapshot.Type); Assert.Null(snapshot.Source); Assert.True(snapshot.OriginInSewer); - Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.Origin))!.CanWrite); - Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.Range))!.CanWrite); - Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.Type))!.CanWrite); - Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.Source))!.CanWrite); - Assert.False(typeof(NPCNoiseEvent).GetProperty(nameof(NPCNoiseEvent.OriginInSewer))!.CanWrite); + } +#endif + + [Theory] + [InlineData(nameof(NPCNoiseEvent.Origin), typeof(Vector3))] + [InlineData(nameof(NPCNoiseEvent.Range), typeof(float))] + [InlineData(nameof(NPCNoiseEvent.Type), typeof(NPCNoiseType))] + [InlineData(nameof(NPCNoiseEvent.Source), typeof(GameObject))] + [InlineData(nameof(NPCNoiseEvent.OriginInSewer), typeof(bool))] + public void NoiseSnapshotPropertiesAreReadOnly(string propertyName, Type propertyType) + { + PropertyInfo? property = typeof(NPCNoiseEvent).GetProperty(propertyName); + + Assert.NotNull(property); + Assert.Equal(propertyType, property!.PropertyType); + Assert.False(property.CanWrite); } [Fact] diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index c5b0d798..65435db3 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -2827,7 +2827,8 @@ public event Action OnEnterVehicle { add => AddVehicleLifecycleHandler( value, - _enterVehicleRegistrations, + _enterVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), SubscribeEnterVehicle, nameof(OnEnterVehicle)); remove => RemoveVehicleLifecycleHandler( @@ -2845,7 +2846,8 @@ public event Action OnExitVehicle { add => AddVehicleLifecycleHandler( value, - _exitVehicleRegistrations, + _exitVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), SubscribeExitVehicle, nameof(OnExitVehicle)); remove => RemoveVehicleLifecycleHandler( @@ -2992,16 +2994,35 @@ public void ClearConversationCategories() /// public event Action OnExplosionHeard { - add => AddAwarenessHandler(ref _explosionHeardHandlers, value); - remove => RemoveAwarenessHandler(ref _explosionHeardHandlers, value); + add => AddAwarenessHandler( + value, + _explosionHeardRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onExplosionHeard, + ResolveNoiseEvent, + nameof(OnExplosionHeard)); + remove => RemoveAwarenessHandler( + value, + _explosionHeardRegistrations, + nameof(OnExplosionHeard)); } + /// /// 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(ref _gunshotHeardHandlers, value); - remove => RemoveAwarenessHandler(ref _gunshotHeardHandlers, value); + add => AddAwarenessHandler( + value, + _gunshotHeardRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onGunshotHeard, + ResolveNoiseEvent, + nameof(OnGunshotHeard)); + remove => RemoveAwarenessHandler( + value, + _gunshotHeardRegistrations, + nameof(OnGunshotHeard)); } /// @@ -3009,8 +3030,17 @@ public event Action OnGunshotHeard /// public event Action OnHitByCar { - add => AddAwarenessHandler(ref _hitByCarHandlers, value); - remove => RemoveAwarenessHandler(ref _hitByCarHandlers, value); + add => AddAwarenessHandler( + value, + _hitByCarRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onHitByCar, + ResolveVehicle, + nameof(OnHitByCar)); + remove => RemoveAwarenessHandler( + value, + _hitByCarRegistrations, + nameof(OnHitByCar)); } /// @@ -3018,8 +3048,17 @@ public event Action OnHitByCar /// public event Action OnNoticedDrugDealing { - add => AddAwarenessHandler(ref _noticedDrugDealingHandlers, value); - remove => RemoveAwarenessHandler(ref _noticedDrugDealingHandlers, value); + add => AddAwarenessHandler( + value, + _noticedDrugDealingRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedDrugDealing, + ResolvePlayer, + nameof(OnNoticedDrugDealing)); + remove => RemoveAwarenessHandler( + value, + _noticedDrugDealingRegistrations, + nameof(OnNoticedDrugDealing)); } /// @@ -3027,8 +3066,17 @@ public event Action OnNoticedDrugDealing /// public event Action OnNoticedGeneralCrime { - add => AddAwarenessHandler(ref _noticedGeneralCrimeHandlers, value); - remove => RemoveAwarenessHandler(ref _noticedGeneralCrimeHandlers, value); + add => AddAwarenessHandler( + value, + _noticedGeneralCrimeRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedGeneralCrime, + ResolvePlayer, + nameof(OnNoticedGeneralCrime)); + remove => RemoveAwarenessHandler( + value, + _noticedGeneralCrimeRegistrations, + nameof(OnNoticedGeneralCrime)); } /// @@ -3036,8 +3084,17 @@ public event Action OnNoticedGeneralCrime /// public event Action OnNoticedPettyCrime { - add => AddAwarenessHandler(ref _noticedPettyCrimeHandlers, value); - remove => RemoveAwarenessHandler(ref _noticedPettyCrimeHandlers, value); + add => AddAwarenessHandler( + value, + _noticedPettyCrimeRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedPettyCrime, + ResolvePlayer, + nameof(OnNoticedPettyCrime)); + remove => RemoveAwarenessHandler( + value, + _noticedPettyCrimeRegistrations, + nameof(OnNoticedPettyCrime)); } /// @@ -3045,8 +3102,17 @@ public event Action OnNoticedPettyCrime /// public event Action OnNoticedPlayerViolatingCurfew { - add => AddAwarenessHandler(ref _noticedPlayerViolatingCurfewHandlers, value); - remove => RemoveAwarenessHandler(ref _noticedPlayerViolatingCurfewHandlers, value); + add => AddAwarenessHandler( + value, + _noticedPlayerViolatingCurfewRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedPlayerViolatingCurfew, + ResolvePlayer, + nameof(OnNoticedPlayerViolatingCurfew)); + remove => RemoveAwarenessHandler( + value, + _noticedPlayerViolatingCurfewRegistrations, + nameof(OnNoticedPlayerViolatingCurfew)); } /// @@ -3054,8 +3120,17 @@ public event Action OnNoticedPlayerViolatingCurfew /// public event Action OnNoticedSuspiciousPlayer { - add => AddAwarenessHandler(ref _noticedSuspiciousPlayerHandlers, value); - remove => RemoveAwarenessHandler(ref _noticedSuspiciousPlayerHandlers, value); + add => AddAwarenessHandler( + value, + _noticedSuspiciousPlayerRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedSuspiciousPlayer, + ResolvePlayer, + nameof(OnNoticedSuspiciousPlayer)); + remove => RemoveAwarenessHandler( + value, + _noticedSuspiciousPlayerRegistrations, + nameof(OnNoticedSuspiciousPlayer)); } /// @@ -3519,7 +3594,6 @@ private void InitializeAwarenessComponent() awareness.Responses = validCivilianResponses; } - EnsureAwarenessEventHooks(); } private void InitializeBehaviourComponents() @@ -4223,31 +4297,28 @@ private void RestoreRuntimeAvatarAppearance() private NPCSupplier? _supplier; private NPCRelationship? _relationship; private NPCMessaging? _messaging; - private Action? _noticedDrugDealingHandlers; - private Action? _noticedGeneralCrimeHandlers; - private Action? _noticedPettyCrimeHandlers; - private Action? _noticedPlayerViolatingCurfewHandlers; - private Action? _noticedSuspiciousPlayerHandlers; - private Action? _gunshotHeardHandlers; - private Action? _explosionHeardHandlers; - private Action? _hitByCarHandlers; - private S1NPCs.NPCAwareness? _subscribedAwareness; - private Action? _nativeNoticedDrugDealingDispatcher; - private Action? _nativeNoticedGeneralCrimeDispatcher; - private Action? _nativeNoticedPettyCrimeDispatcher; - private Action? _nativeNoticedPlayerViolatingCurfewDispatcher; - private Action? _nativeNoticedSuspiciousPlayerDispatcher; - private Action? _nativeGunshotHeardDispatcher; - private Action? _nativeExplosionHeardDispatcher; - private Action? _nativeHitByCarDispatcher; + 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 readonly ManagedEventRegistrationTracker _enterVehicleRegistrations = - new ManagedEventRegistrationTracker(); - private readonly ManagedEventRegistrationTracker _exitVehicleRegistrations = - new ManagedEventRegistrationTracker(); + private ManagedEventRegistrationTracker? _enterVehicleRegistrations; + private ManagedEventRegistrationTracker? _exitVehicleRegistrations; private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4783,301 +4854,161 @@ private void ClearDealerRecommendationHooks() _recommendationSubscriptions.Clear(); } - private void AddAwarenessHandler( - ref Action? handlers, - Action? handler) - { - if (handler == null) - return; - - handlers += handler; - EnsureAwarenessEventHooks(); - } - - private void RemoveAwarenessHandler( - ref Action? handlers, - Action? handler) + private void AddAwarenessHandler( + Action? handler, + ManagedEventRegistrationTracker> registrations, + Func?> selectEvent, + Func convert, + string eventName) { if (handler == null) return; - handlers -= handler; - RemoveAwarenessEventHooksWhenUnused(); - } - - private void EnsureAwarenessEventHooks() - { - if (!HasAwarenessEventHandlers()) - return; - S1NPCs.NPCAwareness? awareness = S1NPC?.Awareness; - if (awareness == null) + UnityEvent? nativeEvent = awareness == null + ? null + : selectEvent(awareness); + if (nativeEvent == null) return; - if (!ReferenceEquals(awareness, _subscribedAwareness)) - { - RemoveAwarenessEventHooks(); - _subscribedAwareness = awareness; - } - try { - if (_noticedDrugDealingHandlers != null) - { - global::S1API.Utils.EventHelper.AddListener( - GetOrCreateNoticedDrugDealingDispatcher(), - awareness.onNoticedDrugDealing); - } - - if (_noticedGeneralCrimeHandlers != null) + Action nativeHandler = value => { - global::S1API.Utils.EventHelper.AddListener( - GetOrCreateNoticedGeneralCrimeDispatcher(), - awareness.onNoticedGeneralCrime); - } - - if (_noticedPettyCrimeHandlers != null) - { - global::S1API.Utils.EventHelper.AddListener( - GetOrCreateNoticedPettyCrimeDispatcher(), - awareness.onNoticedPettyCrime); - } - - if (_noticedPlayerViolatingCurfewHandlers != null) - { - global::S1API.Utils.EventHelper.AddListener( - GetOrCreateNoticedPlayerViolatingCurfewDispatcher(), - awareness.onNoticedPlayerViolatingCurfew); - } - - if (_noticedSuspiciousPlayerHandlers != null) - { - global::S1API.Utils.EventHelper.AddListener( - GetOrCreateNoticedSuspiciousPlayerDispatcher(), - awareness.onNoticedSuspiciousPlayer); - } - - if (_gunshotHeardHandlers != null) - { - global::S1API.Utils.EventHelper.AddListener( - GetOrCreateGunshotHeardDispatcher(), - awareness.onGunshotHeard); - } - - if (_explosionHeardHandlers != null) - { - global::S1API.Utils.EventHelper.AddListener( - GetOrCreateExplosionHeardDispatcher(), - awareness.onExplosionHeard); - } + try + { + handler(convert(value)); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC.{eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + } + }; - if (_hitByCarHandlers != null) - { - global::S1API.Utils.EventHelper.AddListener( - GetOrCreateHitByCarDispatcher(), - awareness.onHitByCar); - } + global::S1API.Utils.EventHelper.AddListener(nativeHandler, nativeEvent); + registrations.Add( + handler, + new AwarenessEventRegistration(nativeEvent, nativeHandler)); } catch (Exception ex) { - Logger.Warning($"Could not attach NPC awareness event hooks: {ex}"); + Logger.Warning( + $"Could not subscribe to NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); } } - private void RemoveAwarenessEventHooksWhenUnused() - { - if (!HasAwarenessEventHandlers()) - RemoveAwarenessEventHooks(); - } - - private bool HasAwarenessEventHandlers() => - _noticedDrugDealingHandlers != null || - _noticedGeneralCrimeHandlers != null || - _noticedPettyCrimeHandlers != null || - _noticedPlayerViolatingCurfewHandlers != null || - _noticedSuspiciousPlayerHandlers != null || - _gunshotHeardHandlers != null || - _explosionHeardHandlers != null || - _hitByCarHandlers != null; - - private void RemoveAwarenessEventHooks() + private void RemoveAwarenessHandler( + Action? handler, + ManagedEventRegistrationTracker>? registrations, + string eventName) { - S1NPCs.NPCAwareness? awareness = _subscribedAwareness; - if (awareness == null) + if (handler == null || registrations == null || + !registrations.TryTakeLast(handler, out var registration)) return; try { - if (_nativeNoticedDrugDealingDispatcher != null) - { - global::S1API.Utils.EventHelper.RemoveListener( - _nativeNoticedDrugDealingDispatcher, - awareness.onNoticedDrugDealing); - } - - if (_nativeNoticedGeneralCrimeDispatcher != null) - { - global::S1API.Utils.EventHelper.RemoveListener( - _nativeNoticedGeneralCrimeDispatcher, - awareness.onNoticedGeneralCrime); - } - - if (_nativeNoticedPettyCrimeDispatcher != null) - { - global::S1API.Utils.EventHelper.RemoveListener( - _nativeNoticedPettyCrimeDispatcher, - awareness.onNoticedPettyCrime); - } - - if (_nativeNoticedPlayerViolatingCurfewDispatcher != null) - { - global::S1API.Utils.EventHelper.RemoveListener( - _nativeNoticedPlayerViolatingCurfewDispatcher, - awareness.onNoticedPlayerViolatingCurfew); - } - - if (_nativeNoticedSuspiciousPlayerDispatcher != null) - { - global::S1API.Utils.EventHelper.RemoveListener( - _nativeNoticedSuspiciousPlayerDispatcher, - awareness.onNoticedSuspiciousPlayer); - } - - if (_nativeGunshotHeardDispatcher != null) - { - global::S1API.Utils.EventHelper.RemoveListener( - _nativeGunshotHeardDispatcher, - awareness.onGunshotHeard); - } - - if (_nativeExplosionHeardDispatcher != null) - { - global::S1API.Utils.EventHelper.RemoveListener( - _nativeExplosionHeardDispatcher, - awareness.onExplosionHeard); - } - - if (_nativeHitByCarDispatcher != null) - { - global::S1API.Utils.EventHelper.RemoveListener( - _nativeHitByCarDispatcher, - awareness.onHitByCar); - } + global::S1API.Utils.EventHelper.RemoveListener( + registration.Handler, + registration.Event); } catch (Exception ex) { - Logger.Warning($"Could not remove NPC awareness event hooks: {ex}"); - } - finally - { - _subscribedAwareness = null; + registrations.Add(handler, registration); + Logger.Warning( + $"Could not unsubscribe from NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); } } - private Action GetOrCreateNoticedDrugDealingDispatcher() => - _nativeNoticedDrugDealingDispatcher ??= DispatchNoticedDrugDealing; - - private Action GetOrCreateNoticedGeneralCrimeDispatcher() => - _nativeNoticedGeneralCrimeDispatcher ??= DispatchNoticedGeneralCrime; - - private Action GetOrCreateNoticedPettyCrimeDispatcher() => - _nativeNoticedPettyCrimeDispatcher ??= DispatchNoticedPettyCrime; - - private Action GetOrCreateNoticedPlayerViolatingCurfewDispatcher() => - _nativeNoticedPlayerViolatingCurfewDispatcher ??= DispatchNoticedPlayerViolatingCurfew; - - private Action GetOrCreateNoticedSuspiciousPlayerDispatcher() => - _nativeNoticedSuspiciousPlayerDispatcher ??= DispatchNoticedSuspiciousPlayer; - - private Action GetOrCreateGunshotHeardDispatcher() => - _nativeGunshotHeardDispatcher ??= DispatchGunshotHeard; - - private Action GetOrCreateExplosionHeardDispatcher() => - _nativeExplosionHeardDispatcher ??= DispatchExplosionHeard; - - private Action GetOrCreateHitByCarDispatcher() => - _nativeHitByCarDispatcher ??= DispatchHitByCar; - - private void DispatchNoticedDrugDealing(S1PlayerScripts.Player player) => - InvokeAwarenessHandlers( - _noticedDrugDealingHandlers, - ResolvePlayer(player), + private void CleanupAwarenessEventHooks() + { + CleanupAwarenessHandlers( + _noticedDrugDealingRegistrations, nameof(OnNoticedDrugDealing)); - - private void DispatchNoticedGeneralCrime(S1PlayerScripts.Player player) => - InvokeAwarenessHandlers( - _noticedGeneralCrimeHandlers, - ResolvePlayer(player), + CleanupAwarenessHandlers( + _noticedGeneralCrimeRegistrations, nameof(OnNoticedGeneralCrime)); - - private void DispatchNoticedPettyCrime(S1PlayerScripts.Player player) => - InvokeAwarenessHandlers( - _noticedPettyCrimeHandlers, - ResolvePlayer(player), + CleanupAwarenessHandlers( + _noticedPettyCrimeRegistrations, nameof(OnNoticedPettyCrime)); - - private void DispatchNoticedPlayerViolatingCurfew(S1PlayerScripts.Player player) => - InvokeAwarenessHandlers( - _noticedPlayerViolatingCurfewHandlers, - ResolvePlayer(player), + CleanupAwarenessHandlers( + _noticedPlayerViolatingCurfewRegistrations, nameof(OnNoticedPlayerViolatingCurfew)); - - private void DispatchNoticedSuspiciousPlayer(S1PlayerScripts.Player player) => - InvokeAwarenessHandlers( - _noticedSuspiciousPlayerHandlers, - ResolvePlayer(player), + CleanupAwarenessHandlers( + _noticedSuspiciousPlayerRegistrations, nameof(OnNoticedSuspiciousPlayer)); - - private void DispatchGunshotHeard(S1Noise.NoiseEvent noiseEvent) => - InvokeAwarenessHandlers( - _gunshotHeardHandlers, - noiseEvent == null ? null : new NPCNoiseEvent(noiseEvent), + CleanupAwarenessHandlers( + _gunshotHeardRegistrations, nameof(OnGunshotHeard)); - - private void DispatchExplosionHeard(S1Noise.NoiseEvent noiseEvent) => - InvokeAwarenessHandlers( - _explosionHeardHandlers, - noiseEvent == null ? null : new NPCNoiseEvent(noiseEvent), + CleanupAwarenessHandlers( + _explosionHeardRegistrations, nameof(OnExplosionHeard)); - - private void DispatchHitByCar(S1Vehicles.LandVehicle vehicle) => - InvokeAwarenessHandlers( - _hitByCarHandlers, - vehicle == null ? null : new LandVehicle(vehicle), + CleanupAwarenessHandlers( + _hitByCarRegistrations, nameof(OnHitByCar)); + } - private static Player? ResolvePlayer(S1PlayerScripts.Player player) => - player == null - ? null - : Player.All.FirstOrDefault(apiPlayer => apiPlayer.S1Player == player); - - private static void InvokeAwarenessHandlers( - Action? handlers, - T value, + private void CleanupAwarenessHandlers( + ManagedEventRegistrationTracker>? registrations, string eventName) { - if (handlers == null) + if (registrations == null) return; - foreach (Action handler in handlers.GetInvocationList()) + foreach (var registration in registrations.TakeAll()) { try { - handler(value); + global::S1API.Utils.EventHelper.RemoveListener( + registration.NativeHandler.Handler, + registration.NativeHandler.Event); } catch (Exception ex) { + registrations.Add( + registration.ManagedHandler, + registration.NativeHandler); Logger.Warning( - $"NPC {eventName} subscriber " + - $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + $"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 Action Handler { get; } + + internal AwarenessEventRegistration( + UnityEvent nativeEvent, + Action handler) + { + Event = nativeEvent; + Handler = handler; + } + } + internal void CleanupRuntimeHooks() { ClearDealerRecommendationHooks(); - RemoveAwarenessEventHooks(); + CleanupAwarenessEventHooks(); _messaging?.Cleanup(); CleanupVehicleLifecycleHooks(); } @@ -5107,11 +5038,12 @@ private void AddVehicleLifecycleHandler( private void RemoveVehicleLifecycleHandler( Action? handler, - ManagedEventRegistrationTracker registrations, + ManagedEventRegistrationTracker? registrations, Action unsubscribe, string eventName) { - if (handler == null || !registrations.TryTakeLast(handler, out var nativeHandler)) + if (handler == null || registrations == null || + !registrations.TryTakeLast(handler, out var nativeHandler)) return; try @@ -5214,10 +5146,13 @@ private void CleanupVehicleLifecycleHooks() } private void CleanupVehicleLifecycleHandlers( - ManagedEventRegistrationTracker registrations, + ManagedEventRegistrationTracker? registrations, Action unsubscribe, string eventName) { + if (registrations == null) + return; + foreach (var registration in registrations.TakeAll()) { try From 65b897cd5ecac7164ab2d578c471c51231a1edd0 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 17:43:56 -0700 Subject: [PATCH 6/7] test(Entities): keep awareness contracts runtime-neutral --- S1API.Tests/Entities/NPCAwarenessApiTests.cs | 35 ++++---------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/S1API.Tests/Entities/NPCAwarenessApiTests.cs b/S1API.Tests/Entities/NPCAwarenessApiTests.cs index 94c7dd10..d601461e 100644 --- a/S1API.Tests/Entities/NPCAwarenessApiTests.cs +++ b/S1API.Tests/Entities/NPCAwarenessApiTests.cs @@ -2,44 +2,23 @@ using System.Reflection; using S1API.Entities; using S1API.Vehicles; -using UnityEngine; namespace S1API.Tests.Entities; public sealed class NPCAwarenessApiTests { -#if MONOMELON - [Fact] - public void NoiseSnapshotCapturesManagedValues() - { - var origin = new Vector3(1f, 2f, 3f); - var snapshot = new NPCNoiseEvent( - origin, - 24f, - NPCNoiseType.Gunshot, - source: null, - originInSewer: true); - - Assert.Equal(origin, snapshot.Origin); - Assert.Equal(24f, snapshot.Range); - Assert.Equal(NPCNoiseType.Gunshot, snapshot.Type); - Assert.Null(snapshot.Source); - Assert.True(snapshot.OriginInSewer); - } -#endif - [Theory] - [InlineData(nameof(NPCNoiseEvent.Origin), typeof(Vector3))] - [InlineData(nameof(NPCNoiseEvent.Range), typeof(float))] - [InlineData(nameof(NPCNoiseEvent.Type), typeof(NPCNoiseType))] - [InlineData(nameof(NPCNoiseEvent.Source), typeof(GameObject))] - [InlineData(nameof(NPCNoiseEvent.OriginInSewer), typeof(bool))] - public void NoiseSnapshotPropertiesAreReadOnly(string propertyName, Type propertyType) + [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(propertyType, property!.PropertyType); + Assert.Equal(propertyTypeName, property!.PropertyType.FullName); Assert.False(property.CanWrite); } From e6187d42ae74eff93cdb8dcc498d242efbbca14a Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 18:45:23 -0700 Subject: [PATCH 7/7] fix(Entities): remove awareness listeners on IL2CPP --- S1API/Entities/NPC.cs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 65435db3..14afd84a 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -4873,7 +4873,7 @@ private void AddAwarenessHandler( try { - Action nativeHandler = value => + Action managedHandler = value => { try { @@ -4887,7 +4887,15 @@ private void AddAwarenessHandler( } }; - global::S1API.Utils.EventHelper.AddListener(nativeHandler, nativeEvent); +#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)); @@ -4910,9 +4918,7 @@ private void RemoveAwarenessHandler( try { - global::S1API.Utils.EventHelper.RemoveListener( - registration.Handler, - registration.Event); + registration.Event.RemoveListener(registration.Handler); } catch (Exception ex) { @@ -4961,9 +4967,8 @@ private void CleanupAwarenessHandlers( { try { - global::S1API.Utils.EventHelper.RemoveListener( - registration.NativeHandler.Handler, - registration.NativeHandler.Event); + registration.NativeHandler.Event.RemoveListener( + registration.NativeHandler.Handler); } catch (Exception ex) { @@ -4994,11 +4999,11 @@ private void CleanupAwarenessHandlers( private sealed class AwarenessEventRegistration { internal UnityEvent Event { get; } - internal Action Handler { get; } + internal UnityAction Handler { get; } internal AwarenessEventRegistration( UnityEvent nativeEvent, - Action handler) + UnityAction handler) { Event = nativeEvent; Handler = handler;