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
172 changes: 172 additions & 0 deletions S1API.Tests/Items/ItemSlotInstanceTests.cs
Original file line number Diff line number Diff line change
@@ -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<NullReferenceException>(() => slot.Quantity);
Assert.Throws<NullReferenceException>(() => slot.ItemInstance);
Assert.Throws<NullReferenceException>(() => 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<string>();
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!);
Comment thread
ifBars marked this conversation as resolved.

internal void ClearLock() =>
ActiveLock = null!;
}
#endif
}
3 changes: 3 additions & 0 deletions S1API.Tests/S1API.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
<Reference Include="ScheduleOne.Core">
<HintPath>$(MonoAssembliesPath)/ScheduleOne.Core.dll</HintPath>
</Reference>
<Reference Include="FishNet.Runtime">
<HintPath>$(MonoAssembliesPath)/FishNet.Runtime.dll</HintPath>
</Reference>
<Reference Include="UnityEngine">
<HintPath>$(MonoAssembliesPath)/UnityEngine.dll</HintPath>
</Reference>
Expand Down
138 changes: 137 additions & 1 deletion S1API/Items/ItemSlotInstance.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,13 +20,24 @@ namespace S1API.Items
/// Represents an item slot within the game.
/// These are present within storage, the hot bar, etc.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class ItemSlotInstance
{
/// <summary>
/// INTERNAL: The reference to the item slot in the game.
/// </summary>
internal readonly S1ItemFramework.ItemSlot S1ItemSlot;

private ManagedEventRegistrationTracker<NativeSlotAction>? _lockedHandlers;
private ManagedEventRegistrationTracker<NativeSlotAction>? _unlockedHandlers;
private ManagedEventRegistrationTracker<NativeSlotAction>? _itemInstanceChangedHandlers;
private ManagedEventRegistrationTracker<NativeSlotAction>? _itemDataChangedHandlers;
private ManagedEventRegistrationTracker<NativeSlotAction>? _filterChangedHandlers;

/// <summary>
/// Creates an item slot instance from the in game slot.
/// </summary>
Expand All @@ -36,6 +51,87 @@ internal ItemSlotInstance(S1ItemFramework.ItemSlot itemSlot) =>
public int Quantity =>
S1ItemSlot.Quantity;

/// <summary>
/// Gets whether the native slot has an active lock on the current peer.
/// </summary>
public bool IsLocked =>
S1ItemSlot.IsLocked;

/// <summary>
/// Gets the native slot's independent flag that prevents adding items.
/// </summary>
public bool IsAddLocked =>
S1ItemSlot.IsAddLocked;

/// <summary>
/// Gets the native slot's independent flag that prevents removing items.
/// </summary>
public bool IsRemovalLocked =>
S1ItemSlot.IsRemovalLocked;

/// <summary>
/// Gets a managed snapshot of the active lock reason, or <c>null</c> when no lock is active.
/// </summary>
/// <remarks>
/// The snapshot contains no native owner and does not change when the slot's lock changes.
/// </remarks>
public ItemSlotLockInfo? ActiveLock
{
get
{
S1ItemFramework.ItemSlotLock? activeLock = S1ItemSlot.ActiveLock;
return activeLock == null ? null : new ItemSlotLockInfo(activeLock.LockReason);
}
}

/// <summary>
/// Occurs when the native slot reports that a lock was applied.
/// </summary>
/// <remarks>
/// Independent changes to <see cref="IsAddLocked"/> or <see cref="IsRemovalLocked"/> do not raise this event.
/// </remarks>
public event Action Locked
{
add => S1ItemSlot.onLocked = AddHandler(S1ItemSlot.onLocked, value, ref _lockedHandlers);
remove => S1ItemSlot.onLocked = RemoveHandler(S1ItemSlot.onLocked, value, _lockedHandlers);
}

/// <summary>
/// Occurs when the native slot reports that its active lock was removed.
/// </summary>
public event Action Unlocked
{
add => S1ItemSlot.onUnlocked = AddHandler(S1ItemSlot.onUnlocked, value, ref _unlockedHandlers);
remove => S1ItemSlot.onUnlocked = RemoveHandler(S1ItemSlot.onUnlocked, value, _unlockedHandlers);
}

/// <summary>
/// Occurs when the native slot reports an item-instance change.
/// </summary>
public event Action ItemInstanceChanged
{
add => S1ItemSlot.onItemInstanceChanged = AddHandler(S1ItemSlot.onItemInstanceChanged, value, ref _itemInstanceChangedHandlers);
remove => S1ItemSlot.onItemInstanceChanged = RemoveHandler(S1ItemSlot.onItemInstanceChanged, value, _itemInstanceChangedHandlers);
}

/// <summary>
/// Occurs when the native slot reports a change to its item data.
/// </summary>
public event Action ItemDataChanged
{
add => S1ItemSlot.onItemDataChanged = AddHandler(S1ItemSlot.onItemDataChanged, value, ref _itemDataChangedHandlers);
remove => S1ItemSlot.onItemDataChanged = RemoveHandler(S1ItemSlot.onItemDataChanged, value, _itemDataChangedHandlers);
}

/// <summary>
/// Occurs when the native slot reports a player-filter change.
/// </summary>
public event Action FilterChanged
{
add => S1ItemSlot.onFilterChange = AddHandler(S1ItemSlot.onFilterChange, value, ref _filterChangedHandlers);
remove => S1ItemSlot.onFilterChange = RemoveHandler(S1ItemSlot.onFilterChange, value, _filterChangedHandlers);
}

/// <summary>
/// The item instance the slot contains.
/// </summary>
Expand Down Expand Up @@ -66,5 +162,45 @@ public ItemInstance? ItemInstance
/// <param name="amount"></param>
public void AddQuantity(int amount) =>
S1ItemSlot.ChangeQuantity(amount);

private static NativeSlotAction? AddHandler(
NativeSlotAction? current,
Action? handler,
ref ManagedEventRegistrationTracker<NativeSlotAction>? 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<NativeSlotAction>(adapter)
?? throw new InvalidOperationException("Could not create the native item-slot delegate.");
NativeSlotAction combined = current == null
? nativeHandler
: Il2CppSystem.Delegate.Combine(current, nativeHandler).Cast<NativeSlotAction>();
#else
NativeSlotAction? combined = current + adapter;
NativeSlotAction nativeHandler = adapter;
#endif
(registrations ??= new ManagedEventRegistrationTracker<NativeSlotAction>()).Add(handler, nativeHandler);
return combined;
}

private static NativeSlotAction? RemoveHandler(
NativeSlotAction? current,
Action? handler,
ManagedEventRegistrationTracker<NativeSlotAction>? registrations)
{
if (handler == null || registrations == null
|| !registrations.TryTakeLast(handler, out NativeSlotAction nativeHandler))
return current;

#if IL2CPPMELON
return Il2CppSystem.Delegate.Remove(current, nativeHandler)?.Cast<NativeSlotAction>();
#else
return current - nativeHandler;
#endif
}
}
}
18 changes: 18 additions & 0 deletions S1API/Items/ItemSlotLockInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace S1API.Items
{
/// <summary>
/// Contains an immutable managed snapshot of an item-slot lock reason.
/// </summary>
public readonly struct ItemSlotLockInfo
{
internal ItemSlotLockInfo(string? lockReason)
{
LockReason = lockReason;
}

/// <summary>
/// Gets the native lock reason, preserving empty strings and <c>null</c>.
/// </summary>
public string? LockReason { get; }
}
}
Loading