diff --git a/S1API.Tests/Items/ItemSlotInstanceTests.cs b/S1API.Tests/Items/ItemSlotInstanceTests.cs new file mode 100644 index 00000000..b8d13359 --- /dev/null +++ b/S1API.Tests/Items/ItemSlotInstanceTests.cs @@ -0,0 +1,172 @@ +using S1API.Items; +#if MONOMELON +using System.Reflection; +using NativeItemSlot = ScheduleOne.ItemFramework.ItemSlot; +using NativeItemSlotLock = ScheduleOne.ItemFramework.ItemSlotLock; +#endif + +namespace S1API.Tests.Items; + +public sealed class ItemSlotInstanceTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" Station task ")] + public void LockSnapshotPreservesReasonWithoutNormalization(string? reason) + { + Assert.Equal(reason, new ItemSlotLockInfo(reason).LockReason); + } + +#if MONOMELON + [Fact] + public void EmptySlotRetainsLegacyDefaultsAndZeroQuantityMutation() + { + var slot = new ItemSlotInstance(new NativeItemSlot()); + + Assert.Equal(0, slot.Quantity); + Assert.Null(slot.ItemInstance); + slot.AddQuantity(amount: 0); + Assert.Equal(0, slot.Quantity); + Assert.Null(slot.ItemInstance); + Assert.False(slot.IsLocked); + Assert.False(slot.IsAddLocked); + Assert.False(slot.IsRemovalLocked); + Assert.Null(slot.ActiveLock); + } + + [Fact] + public void NullNativeSlotStillFailsOnAccessRatherThanConstruction() + { + var slot = new ItemSlotInstance(null!); + + Assert.Throws(() => slot.Quantity); + Assert.Throws(() => slot.ItemInstance); + Assert.Throws(() => slot.AddQuantity(amount: -1)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Station task")] + public void ActiveLockIsASnapshotAndIndependentFlagsRemainIndependent(string? reason) + { + var nativeSlot = new SlotFixture(); + var slot = new ItemSlotInstance(nativeSlot); + int lockEvents = 0; + slot.Locked += () => lockEvents++; + slot.Unlocked += () => lockEvents++; + + nativeSlot.SetIsAddLocked(true); + nativeSlot.SetIsRemovalLocked(true); + Assert.True(slot.IsAddLocked); + Assert.True(slot.IsRemovalLocked); + Assert.False(slot.IsLocked); + Assert.Null(slot.ActiveLock); + Assert.Equal(0, lockEvents); + + nativeSlot.SetLock(reason); + Assert.True(slot.IsLocked); + ItemSlotLockInfo snapshot = slot.ActiveLock!.Value; + Assert.Equal(reason, snapshot.LockReason); + + nativeSlot.SetLock("Replacement"); + Assert.Equal("Replacement", slot.ActiveLock!.Value.LockReason); + Assert.Equal(reason, snapshot.LockReason); + + nativeSlot.ClearLock(); + Assert.False(slot.IsLocked); + Assert.Null(slot.ActiveLock); + Assert.True(slot.IsAddLocked); + Assert.True(slot.IsRemovalLocked); + Assert.Equal(reason, snapshot.LockReason); + } + + [Theory] + [InlineData(nameof(ItemSlotInstance.Locked), "onLocked")] + [InlineData(nameof(ItemSlotInstance.Unlocked), "onUnlocked")] + [InlineData(nameof(ItemSlotInstance.ItemInstanceChanged), "onItemInstanceChanged")] + [InlineData(nameof(ItemSlotInstance.ItemDataChanged), "onItemDataChanged")] + [InlineData(nameof(ItemSlotInstance.FilterChanged), "onFilterChange")] + public void EventsPreserveNativeListenersAndRemoveLastDuplicate(string eventName, string callbackName) + { + var nativeSlot = new NativeItemSlot(); + var slot = new ItemSlotInstance(nativeSlot); + EventInfo managedEvent = typeof(ItemSlotInstance).GetEvent(eventName)!; + FieldInfo nativeCallback = typeof(NativeItemSlot).GetField(callbackName)!; + var calls = new List(); + Action handler = () => calls.Add("shared"); + Action otherHandler = () => calls.Add("other"); + nativeCallback.SetValue(nativeSlot, handler); + + managedEvent.AddEventHandler(slot, null); + managedEvent.RemoveEventHandler(slot, null); + managedEvent.RemoveEventHandler(slot, otherHandler); + Assert.Same(handler, nativeCallback.GetValue(nativeSlot)); + + managedEvent.AddEventHandler(slot, handler); + managedEvent.AddEventHandler(slot, otherHandler); + managedEvent.AddEventHandler(slot, handler); + InvokeAndAssert("shared", "shared", "other", "shared"); + + managedEvent.RemoveEventHandler(slot, handler); + InvokeAndAssert("shared", "shared", "other"); + managedEvent.RemoveEventHandler(slot, handler); + InvokeAndAssert("shared", "other"); + managedEvent.RemoveEventHandler(slot, handler); + InvokeAndAssert("shared", "other"); + managedEvent.RemoveEventHandler(slot, otherHandler); + Assert.Same(handler, nativeCallback.GetValue(nativeSlot)); + + // A fresh wrapper can subscribe after cleanup without retaining old registrations. + var reusedSlot = new ItemSlotInstance(nativeSlot); + managedEvent.AddEventHandler(reusedSlot, otherHandler); + InvokeAndAssert("shared", "other"); + managedEvent.RemoveEventHandler(reusedSlot, otherHandler); + Assert.Same(handler, nativeCallback.GetValue(nativeSlot)); + + void InvokeAndAssert(params string[] expected) + { + calls.Clear(); + ((Action?)nativeCallback.GetValue(nativeSlot))?.Invoke(); + Assert.Equal(expected, calls); + } + } + + [Fact] + public void RemovingHandlersFromOneWrapperLeavesOtherWrappersAndEventsIntact() + { + var nativeSlot = new NativeItemSlot(); + var first = new ItemSlotInstance(nativeSlot); + var second = new ItemSlotInstance(nativeSlot); + var replacementNativeSlot = new NativeItemSlot(); + var replacement = new ItemSlotInstance(replacementNativeSlot); + int calls = 0; + Action handler = () => calls++; + + first.Locked += handler; + second.Locked += handler; + first.Unlocked += handler; + second.Locked -= handler; + replacement.Locked -= handler; + Assert.Null(replacementNativeSlot.onLocked); + + nativeSlot.onLocked(); + nativeSlot.onUnlocked(); + Assert.Equal(2, calls); + first.Locked -= handler; + first.Unlocked -= handler; + Assert.Null(nativeSlot.onLocked); + Assert.Null(nativeSlot.onUnlocked); + } + + private sealed class SlotFixture : NativeItemSlot + { + internal void SetLock(string? reason) => + ActiveLock = new NativeItemSlotLock(this, null!, reason!); + + internal void ClearLock() => + ActiveLock = null!; + } +#endif +} diff --git a/S1API.Tests/S1API.Tests.csproj b/S1API.Tests/S1API.Tests.csproj index e873351d..86b3e364 100644 --- a/S1API.Tests/S1API.Tests.csproj +++ b/S1API.Tests/S1API.Tests.csproj @@ -41,6 +41,9 @@ $(MonoAssembliesPath)/ScheduleOne.Core.dll + + $(MonoAssembliesPath)/FishNet.Runtime.dll + $(MonoAssembliesPath)/UnityEngine.dll diff --git a/S1API/Items/ItemSlotInstance.cs b/S1API/Items/ItemSlotInstance.cs index 9aae3d27..28306ee4 100644 --- a/S1API/Items/ItemSlotInstance.cs +++ b/S1API/Items/ItemSlotInstance.cs @@ -1,11 +1,15 @@ #if (IL2CPPMELON) +using Il2CppInterop.Runtime; +using NativeSlotAction = Il2CppSystem.Action; using S1ItemFramework = Il2CppScheduleOne.ItemFramework; using S1Product = Il2CppScheduleOne.Product; -#elif MONOMELON +#elif (MONOMELON || MONOBEPINEX) +using NativeSlotAction = System.Action; using S1ItemFramework = ScheduleOne.ItemFramework; using S1Product = ScheduleOne.Product; #endif +using System; using S1API.Internal.Utils; using S1API.Money; using S1API.Products; @@ -16,6 +20,11 @@ namespace S1API.Items /// Represents an item slot within the game. /// These are present within storage, the hot bar, etc. /// + /// + /// Lock state and events reflect native state delivered to the current peer. They do not grant + /// authority or guarantee network callback ordering. Events belong to this wrapper: retain it + /// to unsubscribe before discarding it, and subscribe again when a save reload replaces the slot. + /// public class ItemSlotInstance { /// @@ -23,6 +32,12 @@ public class ItemSlotInstance /// internal readonly S1ItemFramework.ItemSlot S1ItemSlot; + private ManagedEventRegistrationTracker? _lockedHandlers; + private ManagedEventRegistrationTracker? _unlockedHandlers; + private ManagedEventRegistrationTracker? _itemInstanceChangedHandlers; + private ManagedEventRegistrationTracker? _itemDataChangedHandlers; + private ManagedEventRegistrationTracker? _filterChangedHandlers; + /// /// Creates an item slot instance from the in game slot. /// @@ -36,6 +51,87 @@ internal ItemSlotInstance(S1ItemFramework.ItemSlot itemSlot) => public int Quantity => S1ItemSlot.Quantity; + /// + /// Gets whether the native slot has an active lock on the current peer. + /// + public bool IsLocked => + S1ItemSlot.IsLocked; + + /// + /// Gets the native slot's independent flag that prevents adding items. + /// + public bool IsAddLocked => + S1ItemSlot.IsAddLocked; + + /// + /// Gets the native slot's independent flag that prevents removing items. + /// + public bool IsRemovalLocked => + S1ItemSlot.IsRemovalLocked; + + /// + /// Gets a managed snapshot of the active lock reason, or null when no lock is active. + /// + /// + /// The snapshot contains no native owner and does not change when the slot's lock changes. + /// + public ItemSlotLockInfo? ActiveLock + { + get + { + S1ItemFramework.ItemSlotLock? activeLock = S1ItemSlot.ActiveLock; + return activeLock == null ? null : new ItemSlotLockInfo(activeLock.LockReason); + } + } + + /// + /// Occurs when the native slot reports that a lock was applied. + /// + /// + /// Independent changes to or do not raise this event. + /// + public event Action Locked + { + add => S1ItemSlot.onLocked = AddHandler(S1ItemSlot.onLocked, value, ref _lockedHandlers); + remove => S1ItemSlot.onLocked = RemoveHandler(S1ItemSlot.onLocked, value, _lockedHandlers); + } + + /// + /// Occurs when the native slot reports that its active lock was removed. + /// + public event Action Unlocked + { + add => S1ItemSlot.onUnlocked = AddHandler(S1ItemSlot.onUnlocked, value, ref _unlockedHandlers); + remove => S1ItemSlot.onUnlocked = RemoveHandler(S1ItemSlot.onUnlocked, value, _unlockedHandlers); + } + + /// + /// Occurs when the native slot reports an item-instance change. + /// + public event Action ItemInstanceChanged + { + add => S1ItemSlot.onItemInstanceChanged = AddHandler(S1ItemSlot.onItemInstanceChanged, value, ref _itemInstanceChangedHandlers); + remove => S1ItemSlot.onItemInstanceChanged = RemoveHandler(S1ItemSlot.onItemInstanceChanged, value, _itemInstanceChangedHandlers); + } + + /// + /// Occurs when the native slot reports a change to its item data. + /// + public event Action ItemDataChanged + { + add => S1ItemSlot.onItemDataChanged = AddHandler(S1ItemSlot.onItemDataChanged, value, ref _itemDataChangedHandlers); + remove => S1ItemSlot.onItemDataChanged = RemoveHandler(S1ItemSlot.onItemDataChanged, value, _itemDataChangedHandlers); + } + + /// + /// Occurs when the native slot reports a player-filter change. + /// + public event Action FilterChanged + { + add => S1ItemSlot.onFilterChange = AddHandler(S1ItemSlot.onFilterChange, value, ref _filterChangedHandlers); + remove => S1ItemSlot.onFilterChange = RemoveHandler(S1ItemSlot.onFilterChange, value, _filterChangedHandlers); + } + /// /// The item instance the slot contains. /// @@ -66,5 +162,45 @@ public ItemInstance? ItemInstance /// public void AddQuantity(int amount) => S1ItemSlot.ChangeQuantity(amount); + + private static NativeSlotAction? AddHandler( + NativeSlotAction? current, + Action? handler, + ref ManagedEventRegistrationTracker? registrations) + { + if (handler == null) + return current; + + // A distinct adapter keeps removal from touching an identical native listener. + Action adapter = () => handler(); +#if IL2CPPMELON + NativeSlotAction nativeHandler = DelegateSupport.ConvertDelegate(adapter) + ?? throw new InvalidOperationException("Could not create the native item-slot delegate."); + NativeSlotAction combined = current == null + ? nativeHandler + : Il2CppSystem.Delegate.Combine(current, nativeHandler).Cast(); +#else + NativeSlotAction? combined = current + adapter; + NativeSlotAction nativeHandler = adapter; +#endif + (registrations ??= new ManagedEventRegistrationTracker()).Add(handler, nativeHandler); + return combined; + } + + private static NativeSlotAction? RemoveHandler( + NativeSlotAction? current, + Action? handler, + ManagedEventRegistrationTracker? registrations) + { + if (handler == null || registrations == null + || !registrations.TryTakeLast(handler, out NativeSlotAction nativeHandler)) + return current; + +#if IL2CPPMELON + return Il2CppSystem.Delegate.Remove(current, nativeHandler)?.Cast(); +#else + return current - nativeHandler; +#endif + } } } diff --git a/S1API/Items/ItemSlotLockInfo.cs b/S1API/Items/ItemSlotLockInfo.cs new file mode 100644 index 00000000..3af16df3 --- /dev/null +++ b/S1API/Items/ItemSlotLockInfo.cs @@ -0,0 +1,18 @@ +namespace S1API.Items +{ + /// + /// Contains an immutable managed snapshot of an item-slot lock reason. + /// + public readonly struct ItemSlotLockInfo + { + internal ItemSlotLockInfo(string? lockReason) + { + LockReason = lockReason; + } + + /// + /// Gets the native lock reason, preserving empty strings and null. + /// + public string? LockReason { get; } + } +}