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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs
Original file line number Diff line number Diff line change
@@ -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<LandVehicle>), eventInfo!.EventHandlerType);
}
}

internal static class NPCVehicleLifecycleApiCompileFixture
{
internal static void SubscribeAndUnsubscribe(NPC npc)
{
Action<LandVehicle> handler = _ => { };

npc.OnEnterVehicle += handler;
npc.OnExitVehicle += handler;
npc.OnEnterVehicle -= handler;
npc.OnExitVehicle -= handler;
}
}
24 changes: 24 additions & 0 deletions S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
Action firstHandler = () => { };
Action<int> 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 _));
}
}
206 changes: 200 additions & 6 deletions S1API/Entities/NPC.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#if (IL2CPPMELON)
using NativeVehicleLifecycleAction = Il2CppSystem.Action<Il2CppScheduleOne.Vehicles.LandVehicle>;
using S1DevUtilities = Il2CppScheduleOne.DevUtilities;
using S1AvatarEquipping = Il2CppScheduleOne.AvatarFramework.Equipping;
using S1Dialogue = Il2CppScheduleOne.Dialogue;
Expand Down Expand Up @@ -28,6 +29,7 @@
using S1Money = Il2CppScheduleOne.Money;
using ConversationCategoryList = Il2CppSystem.Collections.Generic.List<Il2CppScheduleOne.Messaging.EConversationCategory>;
#elif MONOMELON
using NativeVehicleLifecycleAction = System.Action<ScheduleOne.Vehicles.LandVehicle>;
using S1DevUtilities = ScheduleOne.DevUtilities;
using S1AvatarEquipping = ScheduleOne.AvatarFramework.Equipping;
using S1Dialogue = ScheduleOne.Dialogue;
Expand Down Expand Up @@ -2817,6 +2819,44 @@ public Map.Building? CurrentBuilding
public LandVehicle? CurrentVehicle =>
S1NPC.CurrentVehicle != null ? new LandVehicle(S1NPC.CurrentVehicle) : null;

/// <summary>
/// Occurs when the NPC enters a vehicle.
/// </summary>
/// <remarks>This event preserves the timing of the native vehicle-entry callback.</remarks>
public event Action<LandVehicle> OnEnterVehicle
{
add => AddVehicleLifecycleHandler(
value,
_enterVehicleRegistrations ??=
new ManagedEventRegistrationTracker<NativeVehicleLifecycleAction>(),
SubscribeEnterVehicle,
nameof(OnEnterVehicle));
remove => RemoveVehicleLifecycleHandler(
value,
_enterVehicleRegistrations,
UnsubscribeEnterVehicle,
nameof(OnEnterVehicle));
}

/// <summary>
/// Occurs when the NPC exits a vehicle.
/// </summary>
/// <remarks>This event preserves the timing of the native vehicle-exit callback.</remarks>
public event Action<LandVehicle> OnExitVehicle
{
add => AddVehicleLifecycleHandler(
value,
_exitVehicleRegistrations ??=
new ManagedEventRegistrationTracker<NativeVehicleLifecycleAction>(),
SubscribeExitVehicle,
nameof(OnExitVehicle));
remove => RemoveVehicleLifecycleHandler(
value,
_exitVehicleRegistrations,
UnsubscribeExitVehicle,
nameof(OnExitVehicle));
}

// TODO: Add Inventory (currently missing NPCInventory abstraction)
// public ??? Inventory { get; set; }

Expand Down Expand Up @@ -2949,12 +2989,6 @@ public void ClearConversationCategories()
}
}

// TODO: Add OnEnterVehicle listener (currently missing LandVehicle abstraction)
// public event Action OnEnterVehicle { }

// TODO: Add OnExitVehicle listener (currently missing LandVehicle abstraction)
// public event Action OnExitVehicle { }

// TODO: Add OnExplosionHeard listener (currently missing NoiseEvent abstraction)
// public event Action OnExplosionHeard { }

Expand Down Expand Up @@ -4146,6 +4180,8 @@ private void RestoreRuntimeAvatarAppearance()
private NPCSprayPainting? _sprayPainting;
private NPCDrinking? _drinking;
private NPCItemHolding? _itemHolding;
private ManagedEventRegistrationTracker<NativeVehicleLifecycleAction>? _enterVehicleRegistrations;
private ManagedEventRegistrationTracker<NativeVehicleLifecycleAction>? _exitVehicleRegistrations;
private bool _relationshipDataAppliedFromPrefab;
private float? _loadedRelationshipDelta;
private bool _loadedRelationshipUnlocked;
Expand Down Expand Up @@ -4685,6 +4721,164 @@ internal void CleanupRuntimeHooks()
{
ClearDealerRecommendationHooks();
_messaging?.Cleanup();
CleanupVehicleLifecycleHooks();
}

private void AddVehicleLifecycleHandler(
Action<LandVehicle>? handler,
ManagedEventRegistrationTracker<NativeVehicleLifecycleAction> registrations,
Action<NativeVehicleLifecycleAction> 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<LandVehicle>? handler,
ManagedEventRegistrationTracker<NativeVehicleLifecycleAction>? registrations,
Action<NativeVehicleLifecycleAction> 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<LandVehicle> handler,
string eventName)
{
Action<S1Vehicles.LandVehicle> 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<NativeVehicleLifecycleAction>(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<NativeVehicleLifecycleAction>();
#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<NativeVehicleLifecycleAction>();
#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<NativeVehicleLifecycleAction>();
#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<NativeVehicleLifecycleAction>();
#else
S1NPC.onExitVehicle -= handler;
#endif
}

private void CleanupVehicleLifecycleHooks()
{
CleanupVehicleLifecycleHandlers(
_enterVehicleRegistrations,
UnsubscribeEnterVehicle,
nameof(OnEnterVehicle));
CleanupVehicleLifecycleHandlers(
_exitVehicleRegistrations,
UnsubscribeExitVehicle,
nameof(OnExitVehicle));
}

private void CleanupVehicleLifecycleHandlers(
ManagedEventRegistrationTracker<NativeVehicleLifecycleAction>? registrations,
Action<NativeVehicleLifecycleAction> 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
Expand Down
10 changes: 7 additions & 3 deletions S1API/Internal/Lifecycle/SceneStateCleaner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -126,4 +131,3 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload)
}
}
}

21 changes: 18 additions & 3 deletions S1API/Internal/Utils/ManagedEventRegistrationTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ namespace S1API.Internal.Utils
/// <typeparam name="TNativeHandler">The runtime-specific handler type.</typeparam>
internal sealed class ManagedEventRegistrationTracker<TNativeHandler>
{
private readonly Dictionary<Action, List<TNativeHandler>> _registrations = new Dictionary<Action, List<TNativeHandler>>();
private readonly Dictionary<Delegate, List<TNativeHandler>> _registrations = new Dictionary<Delegate, List<TNativeHandler>>();

internal void Add(Action managedHandler, TNativeHandler nativeHandler)
internal void Add(Delegate managedHandler, TNativeHandler nativeHandler)
{
if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers))
{
Expand All @@ -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)
Expand All @@ -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;
}
}
}
Loading