diff --git a/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs b/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs new file mode 100644 index 00000000..85f9c8e2 --- /dev/null +++ b/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using S1API.Trash; + +namespace S1API.Tests.Trash; + +public sealed class TrashContainerApiCompatibilityTests +{ + [Fact] + public void WrapperSurfaceUsesManagedTypes() + { + Assert.True(typeof(TrashContainer).IsSealed); + Assert.Equal(typeof(int), GetProperty(nameof(TrashContainer.Capacity)).PropertyType); + Assert.Equal(typeof(int), GetProperty(nameof(TrashContainer.Level)).PropertyType); + Assert.Equal(typeof(float), GetProperty(nameof(TrashContainer.NormalizedLevel)).PropertyType); + Assert.Equal( + typeof(IReadOnlyList), + GetProperty(nameof(TrashContainer.Contents)).PropertyType); + Assert.Equal(typeof(bool), GetProperty(nameof(TrashContainer.CanBeBagged)).PropertyType); + Assert.Equal(typeof(Action), GetEvent(nameof(TrashContainer.OnTrashAdded)).EventHandlerType); + Assert.Equal(typeof(Action), GetEvent(nameof(TrashContainer.OnTrashLevelChanged)).EventHandlerType); + Assert.Equal(typeof(bool), GetMethod(nameof(TrashContainer.TryBagTrash)).ReturnType); + } + + [Fact] + public void ContentEntryIsAnImmutableManagedValue() + { + Assert.True(typeof(TrashContentEntry).IsValueType); + Assert.True(typeof(TrashContentEntry).IsDefined(typeof(System.Runtime.CompilerServices.IsReadOnlyAttribute))); + + PropertyInfo[] properties = typeof(TrashContentEntry).GetProperties(BindingFlags.Instance | BindingFlags.Public); + Assert.Equal(4, properties.Length); + Assert.All(properties, property => Assert.Null(property.SetMethod)); + Assert.DoesNotContain( + typeof(TrashContentEntry).GetFields(BindingFlags.Instance | BindingFlags.Public), + field => !field.IsInitOnly); + } + + [Fact] + public void ConstructionDoesNotExposeNativeTypes() + { + Assert.Empty(typeof(TrashContainer).GetConstructors(BindingFlags.Instance | BindingFlags.Public)); + Assert.Empty(typeof(TrashContentEntry).GetConstructors(BindingFlags.Instance | BindingFlags.Public)); + } + + private static PropertyInfo GetProperty(string name) => + typeof(TrashContainer).GetProperty(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public property {name}."); + + private static EventInfo GetEvent(string name) => + typeof(TrashContainer).GetEvent(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public event {name}."); + + private static MethodInfo GetMethod(string name) => + typeof(TrashContainer).GetMethod(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public method {name}."); +} diff --git a/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs b/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs new file mode 100644 index 00000000..3df394af --- /dev/null +++ b/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using S1API.Trash; +using UnityEngine; + +namespace S1API.Tests.Trash; + +internal static class TrashContainerApiCompileFixture +{ + internal static void ReadAndSubscribe(GameObject gameObject, TrashContainer container) + { + TrashContainer? existing = TrashContainer.FromGameObject(gameObject); + TrashContainer[] containers = TrashContainer.FindInScene(includeInactive: true); + GameObject? owner = container.GameObject; + int capacity = container.Capacity; + int level = container.Level; + float normalizedLevel = container.NormalizedLevel; + IReadOnlyList contents = container.Contents; + bool canBeBagged = container.CanBeBagged; + + Action trashAdded = _ => { }; + Action levelChanged = () => { }; + container.OnTrashAdded += trashAdded; + container.OnTrashLevelChanged += levelChanged; + container.OnTrashAdded -= trashAdded; + container.OnTrashLevelChanged -= levelChanged; + + bool bagged = container.TryBagTrash(); + + _ = existing; + _ = containers; + _ = owner; + _ = capacity; + _ = level; + _ = normalizedLevel; + _ = contents; + _ = canBeBagged; + _ = bagged; + } +} diff --git a/S1API/Trash/TrashContainer.cs b/S1API/Trash/TrashContainer.cs new file mode 100644 index 00000000..7cb4fbbb --- /dev/null +++ b/S1API/Trash/TrashContainer.cs @@ -0,0 +1,425 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime; +using NativeTrashAddedAction = UnityEngine.Events.UnityAction; +using NativeTrashLevelChangedAction = UnityEngine.Events.UnityAction; +using S1InstanceFinder = Il2CppFishNet.InstanceFinder; +using S1Trash = Il2CppScheduleOne.Trash; +#elif MONOMELON +using NativeTrashAddedAction = System.Action; +using NativeTrashLevelChangedAction = System.Action; +using S1InstanceFinder = FishNet.InstanceFinder; +using S1Trash = ScheduleOne.Trash; +#endif + +using System; +using System.Collections.Generic; +using S1API.Internal.Utils; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Trash +{ + /// + /// Provides managed access to an existing native trash container. + /// + /// + /// This wrapper does not add persistence or replacement replication for custom containers. + /// + public sealed class TrashContainer + { + private static readonly Dictionary TrashAddedRegistrations = + new Dictionary(); + + private static readonly Dictionary TrashLevelChangedRegistrations = + new Dictionary(); + + /// + /// INTERNAL: The native trash container. + /// + internal readonly S1Trash.TrashContainer S1TrashContainer; + + /// + /// INTERNAL: Creates a wrapper around a native trash container. + /// + /// The native trash container. + internal TrashContainer(S1Trash.TrashContainer trashContainer) + { + S1TrashContainer = trashContainer; + } + + /// + /// Gets a trash container attached directly to a game object. + /// + /// The game object to inspect. + /// A trash-container wrapper, or null when the game object has no container component. + /// is null or destroyed. + public static TrashContainer? FromGameObject(GameObject gameObject) + { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + PruneDestroyedRegistrationStates(); + S1Trash.TrashContainer? trashContainer = + gameObject.GetComponent(); + return trashContainer == null ? null : new TrashContainer(trashContainer); + } + + /// + /// Finds native trash containers in the loaded scene. + /// + /// Whether to include containers on inactive game objects. + /// A snapshot of the trash containers found in the scene. + public static TrashContainer[] FindInScene(bool includeInactive = false) + { + PruneDestroyedRegistrationStates(); + var nativeContainers = + Object.FindObjectsOfType(includeInactive); + if (nativeContainers == null || nativeContainers.Length == 0) + return Array.Empty(); + + var containers = new List(nativeContainers.Length); + for (int index = 0; index < nativeContainers.Length; index++) + { + S1Trash.TrashContainer nativeContainer = nativeContainers[index]; + if (nativeContainer != null) + containers.Add(new TrashContainer(nativeContainer)); + } + + return containers.ToArray(); + } + + /// + /// Gets the game object that owns the native trash container. + /// + public GameObject? GameObject => + S1TrashContainer?.gameObject; + + /// + /// Gets the maximum number of capacity units the container can hold. + /// + public int Capacity => + S1TrashContainer.TrashCapacity; + + /// + /// Gets the current number of capacity units used by the container contents. + /// + public int Level => + S1TrashContainer.TrashLevel; + + /// + /// Gets the current level divided by the container capacity. + /// + public float NormalizedLevel => + S1TrashContainer.NormalizedTrashLevel; + + /// + /// Gets an immutable managed snapshot of the current content entries. + /// + public IReadOnlyList Contents + { + get + { + var entries = S1TrashContainer.Content?.Entries; + if (entries == null || entries.Count == 0) + return Array.Empty(); + + var snapshot = new List(entries.Count); + for (int index = 0; index < entries.Count; index++) + { + S1Trash.TrashContent.Entry entry = entries[index]; + if (entry == null) + continue; + + snapshot.Add(new TrashContentEntry( + entry.TrashID, + entry.Quantity, + entry.UnitSize, + entry.UnitValue)); + } + + return snapshot.AsReadOnly(); + } + } + + /// + /// Gets whether the native container currently has enough contents to create a trash bag. + /// + public bool CanBeBagged => + S1TrashContainer.CanBeBagged(); + + /// + /// Occurs after the native container adds trash. + /// + /// + /// A handler may be removed through any wrapper for the same native container. + /// Duplicate subscriptions are removed one at a time. + /// + public event Action OnTrashAdded + { + add + { + if (value == null) + return; + + NativeTrashAddedAction nativeHandler = CreateNativeTrashAddedHandler(value); + SubscribeTrashAdded(nativeHandler); + GetTrashAddedRegistrationState().Registrations.Add(value, nativeHandler); + } + remove + { + if (value == null || !TryTakeTrashAddedRegistration( + value, + out TrashAddedRegistrationState state, + out NativeTrashAddedAction nativeHandler)) + return; + + try + { + UnsubscribeTrashAdded(nativeHandler); + } + catch + { + state.Registrations.Add(value, nativeHandler); + throw; + } + + if (state.Registrations.IsEmpty) + TrashAddedRegistrations.Remove(S1TrashContainer.GetInstanceID()); + } + } + + /// + /// Occurs after the native container level changes. + /// + /// + /// A handler may be removed through any wrapper for the same native container. + /// Duplicate subscriptions are removed one at a time. + /// + public event Action OnTrashLevelChanged + { + add + { + if (value == null) + return; + + NativeTrashLevelChangedAction nativeHandler = CreateNativeTrashLevelChangedHandler(value); + SubscribeTrashLevelChanged(nativeHandler); + GetTrashLevelChangedRegistrationState().Registrations.Add(value, nativeHandler); + } + remove + { + if (value == null || !TryTakeTrashLevelChangedRegistration( + value, + out TrashLevelChangedRegistrationState state, + out NativeTrashLevelChangedAction nativeHandler)) + return; + + try + { + UnsubscribeTrashLevelChanged(nativeHandler); + } + catch + { + state.Registrations.Add(value, nativeHandler); + throw; + } + + if (state.Registrations.IsEmpty) + TrashLevelChangedRegistrations.Remove(S1TrashContainer.GetInstanceID()); + } + } + + /// + /// Bags the current contents through the native server-authoritative path when eligible. + /// + /// true when native bagging was invoked; otherwise, false. + public bool TryBagTrash() + { + if (!S1InstanceFinder.IsServer || !S1TrashContainer.CanBeBagged()) + return false; + + S1TrashContainer.BagTrash(); + return true; + } + + private static NativeTrashAddedAction CreateNativeTrashAddedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native trash-added delegate."); +#else + return trashId => handler(trashId); +#endif + } + + private static NativeTrashLevelChangedAction CreateNativeTrashLevelChangedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native trash-level delegate."); +#else + return () => handler(); +#endif + } + + private void SubscribeTrashAdded(NativeTrashAddedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashAdded.AddListener(handler); +#else + global::S1API.Utils.EventHelper.AddListener(handler, S1TrashContainer.onTrashAdded); +#endif + } + + private void UnsubscribeTrashAdded(NativeTrashAddedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashAdded.RemoveListener(handler); +#else + global::S1API.Utils.EventHelper.RemoveListener(handler, S1TrashContainer.onTrashAdded); +#endif + } + + private void SubscribeTrashLevelChanged(NativeTrashLevelChangedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashLevelChanged.AddListener(handler); +#else + global::S1API.Utils.EventHelper.AddListener(handler, S1TrashContainer.onTrashLevelChanged); +#endif + } + + private void UnsubscribeTrashLevelChanged(NativeTrashLevelChangedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashLevelChanged.RemoveListener(handler); +#else + global::S1API.Utils.EventHelper.RemoveListener(handler, S1TrashContainer.onTrashLevelChanged); +#endif + } + + private TrashAddedRegistrationState GetTrashAddedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TrashContainer.GetInstanceID(); + if (TrashAddedRegistrations.TryGetValue(instanceId, out TrashAddedRegistrationState? state)) + return state; + + state = new TrashAddedRegistrationState(S1TrashContainer); + TrashAddedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeTrashAddedRegistration( + Action managedHandler, + out TrashAddedRegistrationState state, + out NativeTrashAddedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (TrashAddedRegistrations.TryGetValue( + S1TrashContainer.GetInstanceID(), + out TrashAddedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = null!; + return false; + } + + private TrashLevelChangedRegistrationState GetTrashLevelChangedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TrashContainer.GetInstanceID(); + if (TrashLevelChangedRegistrations.TryGetValue( + instanceId, + out TrashLevelChangedRegistrationState? state)) + return state; + + state = new TrashLevelChangedRegistrationState(S1TrashContainer); + TrashLevelChangedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeTrashLevelChangedRegistration( + Action managedHandler, + out TrashLevelChangedRegistrationState state, + out NativeTrashLevelChangedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (TrashLevelChangedRegistrations.TryGetValue( + S1TrashContainer.GetInstanceID(), + out TrashLevelChangedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = null!; + return false; + } + + private static void PruneDestroyedRegistrationStates() + { + PruneDestroyedRegistrationStates(TrashAddedRegistrations); + PruneDestroyedRegistrationStates(TrashLevelChangedRegistrations); + } + + private static void PruneDestroyedRegistrationStates(Dictionary registrations) + where TState : TrashContainerRegistrationState + { + List? destroyedIds = null; + foreach (KeyValuePair registration in registrations) + { + if (registration.Value.S1TrashContainer != null) + continue; + + destroyedIds ??= new List(); + destroyedIds.Add(registration.Key); + } + + if (destroyedIds == null) + return; + + foreach (int destroyedId in destroyedIds) + registrations.Remove(destroyedId); + } + + private abstract class TrashContainerRegistrationState + { + internal S1Trash.TrashContainer S1TrashContainer { get; } + + protected TrashContainerRegistrationState(S1Trash.TrashContainer trashContainer) + { + S1TrashContainer = trashContainer; + } + } + + private sealed class TrashAddedRegistrationState : TrashContainerRegistrationState + { + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal TrashAddedRegistrationState(S1Trash.TrashContainer trashContainer) + : base(trashContainer) + { + } + } + + private sealed class TrashLevelChangedRegistrationState : TrashContainerRegistrationState + { + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal TrashLevelChangedRegistrationState(S1Trash.TrashContainer trashContainer) + : base(trashContainer) + { + } + } + } +} diff --git a/S1API/Trash/TrashContentEntry.cs b/S1API/Trash/TrashContentEntry.cs new file mode 100644 index 00000000..2c45e828 --- /dev/null +++ b/S1API/Trash/TrashContentEntry.cs @@ -0,0 +1,36 @@ +namespace S1API.Trash +{ + /// + /// Describes one immutable entry in a trash-container content snapshot. + /// + public readonly struct TrashContentEntry + { + internal TrashContentEntry(string? trashId, int quantity, int unitSize, int unitValue) + { + TrashId = trashId; + Quantity = quantity; + UnitSize = unitSize; + UnitValue = unitValue; + } + + /// + /// Gets the native trash-prefab identifier. + /// + public string? TrashId { get; } + + /// + /// Gets the number of trash items represented by this entry. + /// + public int Quantity { get; } + + /// + /// Gets the container-capacity units consumed by each item. + /// + public int UnitSize { get; } + + /// + /// Gets the sell value of each item. + /// + public int UnitValue { get; } + } +}