Skip to content
Open
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
143 changes: 143 additions & 0 deletions S1API.Tests/Vehicles/VehicleSeatInfoApiTests.cs
Original file line number Diff line number Diff line change
@@ -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<VehicleSeatInfo>),
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<Player?, Player?>),
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<S1Vehicles.VehicleSeat>();
var info = new VehicleSeatInfo(1, seat);
var currentNative = TestObjectFactory.CreateUninitialized<S1PlayerScripts.Player>();
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<Type> 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<Type>()
};

return types
.SelectMany(ExpandType)
.Any(type => type.Namespace?.Contains(
"ScheduleOne.Vehicles",
StringComparison.Ordinal) == true);
}

private static IEnumerable<Type> 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<VehicleSeatInfo> seats = vehicle.Seats;
int index = seat.Index;
bool isDriverSeat = seat.IsDriverSeat;
bool isOccupied = seat.IsOccupied;
Player? occupant = seat.Occupant;
Action<Player?, Player?> handler = (_, _) => { };

seat.OccupantChanged += handler;
seat.OccupantChanged -= handler;

_ = seats;
_ = index;
_ = isDriverSeat;
_ = isOccupied;
_ = occupant;
}
}
57 changes: 57 additions & 0 deletions S1API/Internal/Patches/LandVehiclePatches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; }
}
}
}
49 changes: 49 additions & 0 deletions S1API/Vehicles/LandVehicle.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -48,6 +52,7 @@ public LandVehicle(string vehicleCode)
SetConnection();
UpdateGuidFromGame();
_storage = new StorageInstance(component.Storage);
VehicleRegistry.Register(S1LandVehicle, this);
}

/// <summary>
Expand Down Expand Up @@ -85,6 +90,15 @@ public bool IsOccupied {
set => S1LandVehicle.IsOccupied = value;
}

/// <summary>
/// Gets the vehicle's seats in their native order.
/// </summary>
/// <remarks>
/// The collection is read-only and its seat wrappers retain stable identities while this
/// vehicle wrapper remains valid.
/// </remarks>
public IReadOnlyList<VehicleSeatInfo> Seats => GetSeats();

/// <summary>
/// When this vehicle has started
/// </summary>
Expand Down Expand Up @@ -233,6 +247,7 @@ internal LandVehicle(S1Vehicles.LandVehicle landVehicle)
UpdateGuidFromGame();
_storage = new StorageInstance(landVehicle.Storage);
_isDeferredByName = false;
VehicleRegistry.Register(S1LandVehicle, this);
}

/// <summary>
Expand All @@ -253,6 +268,8 @@ internal LandVehicle(string vehicleName, bool isDeferred)
/// </summary>
private static readonly Log _logger = new Log("S1API.LandVehicle");

private IReadOnlyList<VehicleSeatInfo>? _seats;

/// <summary>
/// Connection to the player that owns the vehicle.
/// </summary>
Expand All @@ -263,6 +280,38 @@ internal LandVehicle(string vehicleName, bool isDeferred)
/// </summary>
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<VehicleSeatInfo> GetSeats()
{
if (_seats != null)
return _seats;

var nativeSeats = S1LandVehicle?.Seats;
if (nativeSeats == null)
return Array.Empty<VehicleSeatInfo>();

var seats = new VehicleSeatInfo[nativeSeats.Length];
for (int i = 0; i < nativeSeats.Length; i++)
seats[i] = new VehicleSeatInfo(i, nativeSeats[i]);

_seats = new ReadOnlyCollection<VehicleSeatInfo>(seats);
return _seats;
}

/// <summary>
/// Sets the connection to the player that owns the vehicle.
/// </summary>
Expand Down
8 changes: 7 additions & 1 deletion S1API/Vehicles/VehicleRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ public static LandVehicle[] GetAll()
{
deferredVehicle.S1LandVehicle = vehicle.S1LandVehicle;
deferredVehicle._isDeferredByName = false;
Register(deferredVehicle.S1LandVehicle, deferredVehicle);
}
}));
return deferredVehicle;
Expand Down Expand Up @@ -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);
}
}
}


Loading
Loading