diff --git a/S1API.Tests/Vehicles/VehicleSeatInfoApiTests.cs b/S1API.Tests/Vehicles/VehicleSeatInfoApiTests.cs new file mode 100644 index 00000000..8e54ba48 --- /dev/null +++ b/S1API.Tests/Vehicles/VehicleSeatInfoApiTests.cs @@ -0,0 +1,143 @@ +using System.Reflection; +using S1API.Entities; +using S1API.Vehicles; + +#if IL2CPPMELON +using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; +using S1Vehicles = Il2CppScheduleOne.Vehicles; +#elif MONOMELON +using S1PlayerScripts = ScheduleOne.PlayerScripts; +using S1Vehicles = ScheduleOne.Vehicles; +#endif + +namespace S1API.Tests.Vehicles; + +public sealed class VehicleSeatInfoApiTests +{ + [Fact] + public void NativeSeatAssignmentPatchPointExistsInTargetRuntime() + { + const BindingFlags flags = + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + Assert.NotNull(typeof(S1Vehicles.LandVehicle).GetMethod( + "SetSeatOccupant", + flags)); + } + + [Fact] + public void WrapperSurfaceIsReadOnlyAndManaged() + { + Assert.Equal( + typeof(IReadOnlyList), + typeof(LandVehicle).GetProperty(nameof(LandVehicle.Seats))!.PropertyType); + Assert.Empty(typeof(VehicleSeatInfo).GetConstructors( + BindingFlags.Public | BindingFlags.Instance)); + + Assert.Equal(typeof(int), GetProperty(nameof(VehicleSeatInfo.Index)).PropertyType); + Assert.Equal(typeof(bool), GetProperty(nameof(VehicleSeatInfo.IsDriverSeat)).PropertyType); + Assert.Equal(typeof(bool), GetProperty(nameof(VehicleSeatInfo.IsOccupied)).PropertyType); + Assert.Equal(typeof(Player), GetProperty(nameof(VehicleSeatInfo.Occupant)).PropertyType); + Assert.All( + typeof(VehicleSeatInfo).GetProperties(BindingFlags.Public | BindingFlags.Instance), + property => Assert.Null(property.SetMethod)); + Assert.Equal( + typeof(Action), + typeof(VehicleSeatInfo).GetEvent(nameof(VehicleSeatInfo.OccupantChanged))!.EventHandlerType); + + Assert.DoesNotContain( + typeof(VehicleSeatInfo).GetMembers(BindingFlags.Public | BindingFlags.Instance), + ExposesNativeVehicleType); + } + + [Fact] + public void ManagedNotificationsSuppressNoOpsAndIsolateSubscribers() + { + var seat = TestObjectFactory.CreateUninitialized(); + var info = new VehicleSeatInfo(1, seat); + var currentNative = TestObjectFactory.CreateUninitialized(); + var current = new Player(currentNative); + int calls = 0; + + try + { + info.OccupantChanged += (_, _) => throw new InvalidOperationException("expected"); + info.OccupantChanged += (observedPrevious, observedCurrent) => + { + Assert.Null(observedPrevious); + Assert.Same(current, observedCurrent); + calls++; + }; + + info.NotifyOccupantChanged(null, null); + info.NotifyOccupantChanged(null, currentNative); + + Assert.Equal(1, calls); + } + finally + { + Player.All.Remove(current); + } + } + + private static PropertyInfo GetProperty(string name) => + typeof(VehicleSeatInfo).GetProperty(name, BindingFlags.Public | BindingFlags.Instance)!; + + private static bool ExposesNativeVehicleType(MemberInfo member) + { + IEnumerable types = member switch + { + PropertyInfo property => new[] { property.PropertyType }, + EventInfo eventInfo when eventInfo.EventHandlerType != null => + new[] { eventInfo.EventHandlerType }, + MethodInfo method => new[] { method.ReturnType } + .Concat(method.GetParameters().Select(parameter => parameter.ParameterType)), + _ => Array.Empty() + }; + + return types + .SelectMany(ExpandType) + .Any(type => type.Namespace?.Contains( + "ScheduleOne.Vehicles", + StringComparison.Ordinal) == true); + } + + private static IEnumerable ExpandType(Type root) + { + yield return root; + + if (root.HasElementType && root.GetElementType() is Type elementType) + { + foreach (Type nested in ExpandType(elementType)) + yield return nested; + } + + foreach (Type argument in root.GetGenericArguments()) + { + foreach (Type nested in ExpandType(argument)) + yield return nested; + } + } +} + +internal static class VehicleSeatInfoApiCompileFixture +{ + internal static void Observe(LandVehicle vehicle, VehicleSeatInfo seat) + { + IReadOnlyList seats = vehicle.Seats; + int index = seat.Index; + bool isDriverSeat = seat.IsDriverSeat; + bool isOccupied = seat.IsOccupied; + Player? occupant = seat.Occupant; + Action handler = (_, _) => { }; + + seat.OccupantChanged += handler; + seat.OccupantChanged -= handler; + + _ = seats; + _ = index; + _ = isDriverSeat; + _ = isOccupied; + _ = occupant; + } +} diff --git a/S1API/Internal/Patches/LandVehiclePatches.cs b/S1API/Internal/Patches/LandVehiclePatches.cs index aa188f84..94c311e3 100644 --- a/S1API/Internal/Patches/LandVehiclePatches.cs +++ b/S1API/Internal/Patches/LandVehiclePatches.cs @@ -4,8 +4,10 @@ #if IL2CPPMELON using S1Vehicles = Il2CppScheduleOne.Vehicles; +using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; #else using S1Vehicles = ScheduleOne.Vehicles; +using S1PlayerScripts = ScheduleOne.PlayerScripts; #endif namespace S1API.Internal.Patches @@ -53,5 +55,60 @@ public static bool SetVisible_Prefix(S1Vehicles.LandVehicle __instance, ref bool } return true; } + + [HarmonyPatch("SetSeatOccupant")] + [HarmonyPrefix] + private static void SetSeatOccupantPrefix( + S1Vehicles.LandVehicle __instance, + int seatIndex, + out SeatOccupancyState __state) + { + __state = default; + if (__instance == null || __instance.Seats == null + || seatIndex < 0 || seatIndex >= __instance.Seats.Length) + { + return; + } + + var seat = __instance.Seats[seatIndex]; + if (seat != null) + __state = new SeatOccupancyState(seatIndex, seat.Occupant); + } + + [HarmonyPatch("SetSeatOccupant")] + [HarmonyPostfix] + private static void SetSeatOccupantPostfix( + S1Vehicles.LandVehicle __instance, + SeatOccupancyState __state) + { + if (!__state.IsValid || __instance == null || __instance.Seats == null + || __state.Index >= __instance.Seats.Length) + { + return; + } + + var seat = __instance.Seats[__state.Index]; + if (seat == null) + return; + + VehicleRegistry.Wrap(__instance)?.NotifySeatOccupantChanged( + __state.Index, + __state.Occupant, + seat.Occupant); + } + + private readonly struct SeatOccupancyState + { + internal SeatOccupancyState(int index, S1PlayerScripts.Player? occupant) + { + Index = index; + Occupant = occupant; + IsValid = true; + } + + internal int Index { get; } + internal S1PlayerScripts.Player? Occupant { get; } + internal bool IsValid { get; } + } } } diff --git a/S1API/Vehicles/LandVehicle.cs b/S1API/Vehicles/LandVehicle.cs index 22af8ba7..6c85ecf0 100644 --- a/S1API/Vehicles/LandVehicle.cs +++ b/S1API/Vehicles/LandVehicle.cs @@ -1,16 +1,20 @@ #if (IL2CPPMELON) using S1Vehicles = Il2CppScheduleOne.Vehicles; +using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; using Il2Cpp; using Il2CppFishNet; using Il2CppFishNet.Connection; using Guid = Il2CppSystem.Guid; #elif MONOMELON using S1Vehicles = ScheduleOne.Vehicles; +using S1PlayerScripts = ScheduleOne.PlayerScripts; using FishNet; using FishNet.Connection; using Guid = System.Guid; #endif using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Reflection; using UnityEngine; using S1API.Internal.Utils; @@ -48,6 +52,7 @@ public LandVehicle(string vehicleCode) SetConnection(); UpdateGuidFromGame(); _storage = new StorageInstance(component.Storage); + VehicleRegistry.Register(S1LandVehicle, this); } /// @@ -85,6 +90,15 @@ public bool IsOccupied { set => S1LandVehicle.IsOccupied = value; } + /// + /// Gets the vehicle's seats in their native order. + /// + /// + /// The collection is read-only and its seat wrappers retain stable identities while this + /// vehicle wrapper remains valid. + /// + public IReadOnlyList Seats => GetSeats(); + /// /// When this vehicle has started /// @@ -233,6 +247,7 @@ internal LandVehicle(S1Vehicles.LandVehicle landVehicle) UpdateGuidFromGame(); _storage = new StorageInstance(landVehicle.Storage); _isDeferredByName = false; + VehicleRegistry.Register(S1LandVehicle, this); } /// @@ -253,6 +268,8 @@ internal LandVehicle(string vehicleName, bool isDeferred) /// private static readonly Log _logger = new Log("S1API.LandVehicle"); + private IReadOnlyList? _seats; + /// /// Connection to the player that owns the vehicle. /// @@ -263,6 +280,38 @@ internal LandVehicle(string vehicleName, bool isDeferred) /// private string _guid = string.Empty; + internal void NotifySeatOccupantChanged( + int seatIndex, + S1PlayerScripts.Player? previous, + S1PlayerScripts.Player? current) + { + if (VehicleSeatInfo.HasSameNativeIdentity(previous, current)) + return; + + var seats = GetSeats(); + if (seatIndex < 0 || seatIndex >= seats.Count) + return; + + seats[seatIndex].NotifyOccupantChanged(previous, current); + } + + private IReadOnlyList GetSeats() + { + if (_seats != null) + return _seats; + + var nativeSeats = S1LandVehicle?.Seats; + if (nativeSeats == null) + return Array.Empty(); + + var seats = new VehicleSeatInfo[nativeSeats.Length]; + for (int i = 0; i < nativeSeats.Length; i++) + seats[i] = new VehicleSeatInfo(i, nativeSeats[i]); + + _seats = new ReadOnlyCollection(seats); + return _seats; + } + /// /// Sets the connection to the player that owns the vehicle. /// diff --git a/S1API/Vehicles/VehicleRegistry.cs b/S1API/Vehicles/VehicleRegistry.cs index 2f0576dd..e95e11cb 100644 --- a/S1API/Vehicles/VehicleRegistry.cs +++ b/S1API/Vehicles/VehicleRegistry.cs @@ -118,6 +118,7 @@ public static LandVehicle[] GetAll() { deferredVehicle.S1LandVehicle = vehicle.S1LandVehicle; deferredVehicle._isDeferredByName = false; + Register(deferredVehicle.S1LandVehicle, deferredVehicle); } })); return deferredVehicle; @@ -215,7 +216,12 @@ public static void RemoveVehicle(string guidString) { _cache[veh] = wrapper; return wrapper; } + + internal static void Register(S1Vehicles.LandVehicle vehicle, LandVehicle wrapper) + { + if (!_cache.ContainsKey(vehicle)) + _cache.Add(vehicle, wrapper); + } } } - diff --git a/S1API/Vehicles/VehicleSeatInfo.cs b/S1API/Vehicles/VehicleSeatInfo.cs new file mode 100644 index 00000000..4156d1ea --- /dev/null +++ b/S1API/Vehicles/VehicleSeatInfo.cs @@ -0,0 +1,125 @@ +#if IL2CPPMELON +using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; +using S1VehicleSeat = Il2CppScheduleOne.Vehicles.VehicleSeat; +#elif MONOMELON +using S1PlayerScripts = ScheduleOne.PlayerScripts; +using S1VehicleSeat = ScheduleOne.Vehicles.VehicleSeat; +#endif + +using System; +using S1API.Entities; +using S1API.Logging; + +namespace S1API.Vehicles +{ + /// + /// Provides read-only metadata and occupancy state for a land-vehicle seat. + /// + public sealed class VehicleSeatInfo + { + private static readonly Log Logger = new Log("VehicleSeatInfo"); + private readonly int _index; + private readonly S1VehicleSeat _seat; + + internal VehicleSeatInfo(int index, S1VehicleSeat seat) + { + _index = index; + _seat = seat; + } + + /// + /// Gets this seat's stable index in . + /// + public int Index => _index; + + /// + /// Gets whether this seat is configured as the driver's seat. + /// + public bool IsDriverSeat => _seat.isDriverSeat; + + /// + /// Gets whether this seat has a native player occupant. + /// + public bool IsOccupied => _seat.isOccupied; + + /// + /// Gets the managed occupant, or when the seat is empty or no + /// managed player wrapper is available. + /// + public Player? Occupant => ResolvePlayer(_seat.Occupant); + + /// + /// Raised after the native occupant of this seat changes. + /// + /// + /// A previous or current value can be when the corresponding + /// native player has no managed wrapper. + /// + public event Action? OccupantChanged; + + internal void NotifyOccupantChanged( + S1PlayerScripts.Player? previous, + S1PlayerScripts.Player? current) + { + if (HasSameNativeIdentity(previous, current)) + return; + + Invoke(OccupantChanged, ResolvePlayer(previous), ResolvePlayer(current)); + } + + private static Player? ResolvePlayer(S1PlayerScripts.Player? native) + { + if (native == null) + return null; + + foreach (Player player in Player.All) + { + if (player.S1Player == native) + return player; + } + + return null; + } + + internal static bool HasSameNativeIdentity( + S1PlayerScripts.Player? previous, + S1PlayerScripts.Player? current) + { + if (ReferenceEquals(previous, current)) + return true; + + if (ReferenceEquals(previous, null) || ReferenceEquals(current, null)) + return false; + + return previous == current; + } + + private static void Invoke( + Action? handlers, + Player? previous, + Player? current) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(previous, current); + } + catch (Exception ex) + { + try + { + Logger.Warning($"An {nameof(OccupantChanged)} subscriber failed: {ex.Message}"); + } + catch + { + // Logging must not prevent the remaining subscribers from running. + } + } + } + } + } +}