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
103 changes: 103 additions & 0 deletions S1API.Tests/Vehicles/VehicleSeatApiTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Reflection;
using S1API.Entities;
using S1API.Internal.Patches;
using S1API.Internal.Vehicles;
using S1API.Vehicles;

namespace S1API.Tests.Vehicles;

public sealed class VehicleSeatApiTests
{
[Fact]
public void AssignmentHookSelectsTheGeneratedMutationInBothRuntimes()
{
var target = Assert.IsAssignableFrom<MethodInfo>(VehicleSeatPatches.TargetMethod());
Assert.StartsWith("RpcLogic___SetSeatOccupant_", target.Name);
Assert.Equal(typeof(int), target.GetParameters()[1].ParameterType);
Assert.Equal(3, target.GetParameters().Length);
}

[Fact]
public void SeatSurfaceIsReadOnlyAndUsesManagedTypes()
{
Assert.True(typeof(VehicleSeatInfo).IsSealed);
Assert.Empty(typeof(VehicleSeatInfo).GetConstructors());
var expected = new Dictionary<string, Type>
{
[nameof(VehicleSeatInfo.Index)] = typeof(int),
[nameof(VehicleSeatInfo.IsDriverSeat)] = typeof(bool),
[nameof(VehicleSeatInfo.IsOccupied)] = typeof(bool),
[nameof(VehicleSeatInfo.Occupant)] = typeof(Player)
};
foreach (var property in typeof(VehicleSeatInfo).GetProperties())
{
Assert.Equal(expected[property.Name], property.PropertyType);
Assert.Null(property.SetMethod);
}
Assert.Equal(typeof(Action<Player?, Player?>),
typeof(VehicleSeatInfo).GetEvent(nameof(VehicleSeatInfo.OccupantChanged))!.EventHandlerType);
Assert.Equal(typeof(IReadOnlyList<VehicleSeatInfo>),
typeof(LandVehicle).GetProperty(nameof(LandVehicle.Seats))!.PropertyType);
// The legacy writable property is a public compatibility contract.
Assert.True(typeof(LandVehicle).GetProperty(nameof(LandVehicle.IsOccupied))!.CanWrite);
}

[Fact]
public void MissingSeatRetainsIndexAndDoesNotEmitNoOpNotifications()
{
var seat = new VehicleSeatInfo(3, null);
int calls = 0;
seat.OccupantChanged += (_, _) => calls++;
seat.Refresh();
seat.Refresh();
Assert.Equal(3, seat.Index);
Assert.False(seat.IsDriverSeat);
Assert.False(seat.IsOccupied);
Assert.Null(seat.Occupant);
Assert.Equal(0, calls);
}

[Fact]
public void UnavailableWrappersDoNotSuppressNativeTransitionNotifications()
{
var seat = new VehicleSeatInfo(0, null);
int calls = 0;
seat.OccupantChanged += (_, _) => throw new InvalidOperationException("expected subscriber failure");
seat.OccupantChanged += (previous, current) =>
{
Assert.Null(previous);
Assert.Null(current);
calls++;
};
seat.NotifyOccupantChanged(null, null);
Assert.Equal(1, calls);
}

[Fact]
public void InvalidationReleasesSubscribersAndIsIdempotent()
{
var seat = new VehicleSeatInfo(2, null);
int calls = 0;
Action<Player?, Player?> handler = (_, _) => calls++;
seat.OccupantChanged += handler;
seat.OccupantChanged -= handler;
seat.NotifyOccupantChanged(null, null);
Assert.Equal(0, calls);
seat.OccupantChanged += handler;
seat.Invalidate();
seat.Invalidate();
seat.NotifyOccupantChanged(null, null);
Assert.Equal(0, calls);
Assert.Equal(2, seat.Index);
Assert.False(seat.IsOccupied);
Assert.Null(seat.Occupant);
}

[Fact]
public void UnavailableVehicleReturnsAnImmutableEmptyCollection()
{
var seats = VehicleSeats.Get(null);
Assert.Empty(seats);
Assert.True(Assert.IsAssignableFrom<ICollection<VehicleSeatInfo>>(seats).IsReadOnly);
}
}
3 changes: 3 additions & 0 deletions S1API/Internal/Patches/LandVehiclePatches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ namespace S1API.Internal.Patches
[HarmonyPatch(typeof(S1Vehicles.LandVehicle))]
internal class LandVehiclePatches
{
/// <summary>Invalidates cached seats and attempts to remove the destroyed vehicle from vehicle tracking.</summary>
/// <param name="__instance">The vehicle whose native destruction callback has completed.</param>
[HarmonyPatch("OnDestroy")]
[HarmonyPostfix]
public static void OnDestroy(S1Vehicles.LandVehicle __instance) {
Internal.Vehicles.VehicleSeats.Remove(__instance);
try
{
if (__instance != null)
Expand Down
39 changes: 39 additions & 0 deletions S1API/Internal/Patches/VehicleSeatPatches.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Linq;
using System.Reflection;
using HarmonyLib;
using S1API.Internal.Vehicles;

#if IL2CPPMELON
using S1Vehicles = Il2CppScheduleOne.Vehicles;
using S1Connection = Il2CppFishNet.Connection.NetworkConnection;
#elif MONOMELON
using S1Vehicles = ScheduleOne.Vehicles;
using S1Connection = FishNet.Connection.NetworkConnection;
#endif

namespace S1API.Internal.Patches
{
[HarmonyPatch]
internal static class VehicleSeatPatches
{
/// <summary>Selects the generated seat-occupant mutation method patched by Harmony.</summary>
/// <returns>The unique method matching the expected name prefix and signature.</returns>
/// <exception cref="InvalidOperationException">No method or more than one method matches.</exception>
internal static MethodBase TargetMethod() =>
typeof(S1Vehicles.LandVehicle)
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Single(method =>
method.Name.StartsWith("RpcLogic___SetSeatOccupant_", StringComparison.Ordinal)
&& method.ReturnType == typeof(void)
&& method.GetParameters().Select(parameter => parameter.ParameterType)
.SequenceEqual(new[] { typeof(S1Connection), typeof(int), typeof(S1Connection) }));

/// <summary>Refreshes an already cached seat after the native occupant mutation completes.</summary>
/// <param name="__instance">The vehicle whose seat was updated.</param>
/// <param name="__1">The native seat-array index passed as the mutation's second argument.</param>
[HarmonyPostfix]
private static void Postfix(S1Vehicles.LandVehicle __instance, int __1) =>
VehicleSeats.Refresh(__instance, __1);
}
}
97 changes: 97 additions & 0 deletions S1API/Internal/Vehicles/VehicleSeats.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using S1API.Lifecycle;
using S1API.Vehicles;

#if IL2CPPMELON
using S1Vehicles = Il2CppScheduleOne.Vehicles;
#elif MONOMELON
using S1Vehicles = ScheduleOne.Vehicles;
#endif

namespace S1API.Internal.Vehicles
{
internal static class VehicleSeats
{
private static readonly Dictionary<S1Vehicles.LandVehicle, IReadOnlyList<VehicleSeatInfo>> Cache =
new Dictionary<S1Vehicles.LandVehicle, IReadOnlyList<VehicleSeatInfo>>();
private static readonly IReadOnlyList<VehicleSeatInfo> Empty = Array.AsReadOnly(Array.Empty<VehicleSeatInfo>());
private static bool _lifecycleHooked;

/// <summary>Gets or creates shared seat wrappers and registers scene cleanup on first caching.</summary>
/// <param name="vehicle">The native vehicle, which may be null or destroyed.</param>
/// <returns>
/// The cached read-only collection in native array order, including wrappers for null slots,
/// or an empty collection if the vehicle or its seat array is unavailable.
/// </returns>
internal static IReadOnlyList<VehicleSeatInfo> Get(S1Vehicles.LandVehicle? vehicle)
{
if (ReferenceEquals(vehicle, null) || vehicle == null)
return Empty;
if (Cache.TryGetValue(vehicle, out var seats))
return seats;

var native = vehicle.Seats;
if (native == null)
return Empty;
var result = new VehicleSeatInfo[native.Length];
for (int i = 0; i < native.Length; i++)
result[i] = new VehicleSeatInfo(i, native[i]);
seats = Array.AsReadOnly(result);
Cache.Add(vehicle, seats);
if (!_lifecycleHooked)
{
GameLifecycle.OnPreSceneChange += Clear;
_lifecycleHooked = true;
}
return seats;
}

/// <summary>Refreshes one cached seat, notifying subscribers if its native occupant changed.</summary>
/// <param name="vehicle">The vehicle whose cached seat should be refreshed.</param>
/// <param name="index">The native seat-array index; out-of-range indices are ignored.</param>
/// <remarks>Does nothing if the vehicle has no cached seat collection.</remarks>
internal static void Refresh(S1Vehicles.LandVehicle vehicle, int index)
{
if (Cache.TryGetValue(vehicle, out var seats) && index >= 0 && index < seats.Count)
seats[index].Refresh();
}

/// <summary>Refreshes cached seats and removes destroyed vehicles during an API update.</summary>
/// <remarks>Occupant changes can notify subscribers; removing a vehicle does not emit exits.</remarks>
internal static void Tick()
{
if (Cache.Count == 0)
return;
// Callbacks may destroy vehicles or resolve more seat collections.
foreach (var entry in new List<KeyValuePair<S1Vehicles.LandVehicle, IReadOnlyList<VehicleSeatInfo>>>(Cache))
{
if (entry.Key == null)
Remove(entry.Key!);
else
foreach (var seat in entry.Value)
seat.Refresh();
}
}

/// <summary>Removes cached seats and releases their subscriptions without emitting exit events.</summary>
/// <param name="vehicle">The vehicle to remove; an uncached vehicle is ignored.</param>
internal static void Remove(S1Vehicles.LandVehicle vehicle)
{
if (!Cache.TryGetValue(vehicle, out var seats))
return;
Cache.Remove(vehicle);
foreach (var seat in seats)
seat.Invalidate();
}

/// <summary>Invalidates all cached seats and releases their subscriptions without emitting exit events.</summary>
internal static void Clear()
{
foreach (var seats in Cache.Values)
foreach (var seat in seats)
seat.Invalidate();
Cache.Clear();
}
}
}
8 changes: 8 additions & 0 deletions S1API/S1API.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ public override void OnInitializeMelon()
}
}

/// <summary>Releases tracked seats and resets internal runtime systems when the mod shuts down.</summary>
public override void OnDeinitializeMelon()
{
Internal.Vehicles.VehicleSeats.Clear();
Internal.Compatibility.AvatarCompatibility.ResetRendering();
Rendering.IconFactory.ResetAccessoryIconState();
WeatherRuntime.ResetBindings();
Expand All @@ -48,8 +50,10 @@ public override void OnDeinitializeMelon()
UnityExceptionTraceHook.Remove();
}

/// <summary>Refreshes tracked seat occupancy and advances the API's runtime systems each frame.</summary>
public override void OnUpdate()
{
Internal.Vehicles.VehicleSeats.Tick();
WeatherRuntime.Tick();
PresentationWorkbenchRuntime.Tick();
CutsceneManager.Tick(UnityEngine.Time.unscaledDeltaTime);
Expand All @@ -69,8 +73,12 @@ public override void OnSceneWasLoaded(int buildIndex, string sceneName)
}
}

/// <summary>Clears tracked seats and scene state, resetting the game lifecycle when Main unloads.</summary>
/// <param name="buildIndex">The unloaded scene's build index; unused by this callback.</param>
/// <param name="sceneName">The unloaded scene's name; Main and Tutorial receive additional cleanup.</param>
public override void OnSceneWasUnloaded(int buildIndex, string sceneName)
{
Internal.Vehicles.VehicleSeats.Clear();
NPCWorkbenchRuntime.Close();
PresentationWorkbenchRuntime.Close();
CutsceneManager.CleanupForSceneChange();
Expand Down
11 changes: 11 additions & 0 deletions S1API/Vehicles/LandVehicle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Guid = System.Guid;
#endif
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using S1API.Internal.Utils;
Expand All @@ -24,6 +25,16 @@ namespace S1API.Vehicles
/// </summary>
public class LandVehicle
{
/// <summary>
/// Gets read-only player seats in native array order, sharing seat objects across vehicle lookups.
/// </summary>
/// <remarks>
/// Resolve again after a scene change. Seat indices are native indices, including empty slots.
/// This API does not include NPC occupants or control entry, exit, or reservation.
/// </remarks>
public IReadOnlyList<VehicleSeatInfo> Seats =>
Internal.Vehicles.VehicleSeats.Get(S1LandVehicle);

// Public members intended to be used by modders
#region Public Members
/// <summary>
Expand Down
Loading
Loading