Dalamud/Dalamud/Game/Inventory/GameInventory.cs
2025-11-08 11:31:10 +01:00

544 lines
19 KiB
C#

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Game.Gui;
using Dalamud.Game.Inventory.InventoryEventArgTypes;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Plugin.Internal;
using Dalamud.Plugin.Services;
namespace Dalamud.Game.Inventory;
/// <summary>
/// This class provides events for the players in-game inventory.
/// </summary>
[ServiceManager.EarlyLoadedService]
internal class GameInventory : IInternalDisposableService
{
private readonly List<GameInventoryPluginScoped> subscribersPendingChange = new();
private readonly List<GameInventoryPluginScoped> subscribers = new();
private readonly List<InventoryItemAddedArgs> addedEvents = new();
private readonly List<InventoryItemRemovedArgs> removedEvents = new();
private readonly List<InventoryItemChangedArgs> changedEvents = new();
private readonly List<InventoryItemMovedArgs> movedEvents = new();
private readonly List<InventoryItemSplitArgs> splitEvents = new();
private readonly List<InventoryItemMergedArgs> mergedEvents = new();
[ServiceManager.ServiceDependency]
private readonly Framework framework = Service<Framework>.Get();
[ServiceManager.ServiceDependency]
private readonly GameGui gameGui = Service<GameGui>.Get();
private readonly GameInventoryType[] inventoryTypes;
private readonly GameInventoryItem[]?[] inventoryItems;
private bool subscribersChanged;
private bool inventoriesMightBeChanged;
[ServiceManager.ServiceConstructor]
private GameInventory()
{
this.inventoryTypes = Enum.GetValues<GameInventoryType>();
this.inventoryItems = new GameInventoryItem[this.inventoryTypes.Length][];
this.gameGui.AgentUpdate += this.OnAgentUpdate;
}
/// <inheritdoc/>
void IInternalDisposableService.DisposeService()
{
lock (this.subscribersPendingChange)
{
this.subscribers.Clear();
this.subscribersPendingChange.Clear();
this.subscribersChanged = false;
this.framework.Update -= this.OnFrameworkUpdate;
this.gameGui.AgentUpdate -= this.OnAgentUpdate;
}
}
/// <summary>
/// Subscribe to events.
/// </summary>
/// <param name="s">The event target.</param>
public void Subscribe(GameInventoryPluginScoped s)
{
lock (this.subscribersPendingChange)
{
this.subscribersPendingChange.Add(s);
this.subscribersChanged = true;
if (this.subscribersPendingChange.Count == 1)
{
this.inventoriesMightBeChanged = true;
this.framework.Update += this.OnFrameworkUpdate;
}
}
}
/// <summary>
/// Unsubscribe from events.
/// </summary>
/// <param name="s">The event target.</param>
public void Unsubscribe(GameInventoryPluginScoped s)
{
lock (this.subscribersPendingChange)
{
if (!this.subscribersPendingChange.Remove(s))
return;
this.subscribersChanged = true;
if (this.subscribersPendingChange.Count == 0)
this.framework.Update -= this.OnFrameworkUpdate;
}
}
private void OnFrameworkUpdate(IFramework framework1)
{
if (!this.inventoriesMightBeChanged)
return;
this.inventoriesMightBeChanged = false;
for (var i = 0; i < this.inventoryTypes.Length; i++)
{
var newItems = GameInventoryItem.GetReadOnlySpanOfInventory(this.inventoryTypes[i]);
if (newItems.IsEmpty)
continue;
// Assumption: newItems is sorted by slots, and the last item has the highest slot number.
var oldItems = this.inventoryItems[i] ??= this.CreateItemsArray(newItems[^1].InternalItem.Slot + 1);
foreach (ref readonly var newItem in newItems)
{
ref var oldItem = ref oldItems[newItem.InternalItem.Slot];
if (oldItem.IsEmpty)
{
if (!newItem.IsEmpty)
{
this.addedEvents.Add(new(newItem));
oldItem = newItem;
}
}
else
{
if (newItem.IsEmpty)
{
this.removedEvents.Add(new(oldItem));
oldItem = newItem;
}
else if (!oldItem.Equals(newItem))
{
this.changedEvents.Add(new(oldItem, newItem));
oldItem = newItem;
}
}
}
}
// Was there any change? If not, stop further processing.
// Note that only these three are checked; the rest will be populated after this check.
if (this.addedEvents.Count == 0 && this.removedEvents.Count == 0 && this.changedEvents.Count == 0)
return;
// Make a copy of subscribers, to accommodate self removal during the loop.
if (this.subscribersChanged)
{
bool isNew;
lock (this.subscribersPendingChange)
{
isNew = this.subscribersPendingChange.Any() && !this.subscribers.Any();
this.subscribers.Clear();
this.subscribers.AddRange(this.subscribersPendingChange);
this.subscribersChanged = false;
}
// Is this the first time (resuming) scanning for changes? Then discard the "changes".
if (isNew)
{
this.addedEvents.Clear();
this.removedEvents.Clear();
this.changedEvents.Clear();
return;
}
}
// Broadcast InventoryChangedRaw.
// Same reason with the above on why are there 3 lists of events involved.
var allRawEventsCollection = new DeferredReadOnlyCollection<InventoryEventArgs>(
this.addedEvents.Count +
this.removedEvents.Count +
this.changedEvents.Count,
() => Array.Empty<InventoryEventArgs>()
.Concat(this.addedEvents)
.Concat(this.removedEvents)
.Concat(this.changedEvents));
foreach (var s in this.subscribers)
s.InvokeChangedRaw(allRawEventsCollection);
// Resolve moved items, from 1 added + 1 removed event.
for (var iAdded = this.addedEvents.Count - 1; iAdded >= 0; --iAdded)
{
var added = this.addedEvents[iAdded];
for (var iRemoved = this.removedEvents.Count - 1; iRemoved >= 0; --iRemoved)
{
var removed = this.removedEvents[iRemoved];
if (added.Item.ItemId != removed.Item.ItemId)
continue;
this.movedEvents.Add(new(removed, added));
// Remove the reinterpreted entries.
this.addedEvents.RemoveAt(iAdded);
this.removedEvents.RemoveAt(iRemoved);
break;
}
}
// Resolve moved items, from 2 changed events.
for (var i = this.changedEvents.Count - 1; i >= 0; --i)
{
var e1 = this.changedEvents[i];
for (var j = i - 1; j >= 0; --j)
{
var e2 = this.changedEvents[j];
if (e1.Item.ItemId != e2.OldItemState.ItemId || e1.OldItemState.ItemId != e2.Item.ItemId)
continue;
// Move happened, and e2 has an item.
if (!e2.Item.IsEmpty)
this.movedEvents.Add(new(e1, e2));
// Move happened, and e1 has an item.
if (!e1.Item.IsEmpty)
this.movedEvents.Add(new(e2, e1));
// Remove the reinterpreted entries. Note that i > j.
this.changedEvents.RemoveAt(i);
this.changedEvents.RemoveAt(j);
// We've removed two. Adjust the outer counter.
--i;
break;
}
}
// Resolve split items, from 1 added + 1 changed event.
for (var iAdded = this.addedEvents.Count - 1; iAdded >= 0; --iAdded)
{
var added = this.addedEvents[iAdded];
for (var iChanged = this.changedEvents.Count - 1; iChanged >= 0; --iChanged)
{
var changed = this.changedEvents[iChanged];
if (added.Item.ItemId != changed.Item.ItemId || added.Item.ItemId != changed.OldItemState.ItemId)
continue;
this.splitEvents.Add(new(changed, added));
// Remove the reinterpreted entries.
this.addedEvents.RemoveAt(iAdded);
this.changedEvents.RemoveAt(iChanged);
break;
}
}
// Resolve merged items, from 1 removed + 1 changed event.
for (var iRemoved = this.removedEvents.Count - 1; iRemoved >= 0; --iRemoved)
{
var removed = this.removedEvents[iRemoved];
for (var iChanged = this.changedEvents.Count - 1; iChanged >= 0; --iChanged)
{
var changed = this.changedEvents[iChanged];
if (removed.Item.ItemId != changed.Item.ItemId || removed.Item.ItemId != changed.OldItemState.ItemId)
continue;
this.mergedEvents.Add(new(removed, changed));
// Remove the reinterpreted entries.
this.removedEvents.RemoveAt(iRemoved);
this.changedEvents.RemoveAt(iChanged);
break;
}
}
// Create a collection view of all events.
var allEventsCollection = new DeferredReadOnlyCollection<InventoryEventArgs>(
this.addedEvents.Count +
this.removedEvents.Count +
this.changedEvents.Count +
this.movedEvents.Count +
this.splitEvents.Count +
this.mergedEvents.Count,
() => Array.Empty<InventoryEventArgs>()
.Concat(this.addedEvents)
.Concat(this.removedEvents)
.Concat(this.changedEvents)
.Concat(this.movedEvents)
.Concat(this.splitEvents)
.Concat(this.mergedEvents));
// Broadcast the rest.
foreach (var s in this.subscribers)
{
s.InvokeChanged(allEventsCollection);
s.Invoke(this.addedEvents);
s.Invoke(this.removedEvents);
s.Invoke(this.changedEvents);
s.Invoke(this.movedEvents);
s.Invoke(this.splitEvents);
s.Invoke(this.mergedEvents);
}
// We're done using the lists. Clean them up.
this.addedEvents.Clear();
this.removedEvents.Clear();
this.changedEvents.Clear();
this.movedEvents.Clear();
this.splitEvents.Clear();
this.mergedEvents.Clear();
}
private GameInventoryItem[] CreateItemsArray(int length)
{
var items = new GameInventoryItem[length];
items.Initialize();
return items;
}
private void OnAgentUpdate(AgentUpdateFlag agentUpdateFlag)
{
this.inventoriesMightBeChanged |= true;
}
/// <summary>
/// A <see cref="IReadOnlyCollection{T}"/> view of <see cref="IEnumerable{T}"/>, so that the number of items
/// contained within can be known in advance, and it can be enumerated multiple times.
/// </summary>
/// <typeparam name="T">The type of elements being enumerated.</typeparam>
private class DeferredReadOnlyCollection<T> : IReadOnlyCollection<T>
{
private readonly Func<IEnumerable<T>> enumerableGenerator;
public DeferredReadOnlyCollection(int count, Func<IEnumerable<T>> enumerableGenerator)
{
this.enumerableGenerator = enumerableGenerator;
this.Count = count;
}
public int Count { get; }
public IEnumerator<T> GetEnumerator() => this.enumerableGenerator().GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => this.enumerableGenerator().GetEnumerator();
}
}
/// <summary>
/// Plugin-scoped version of a GameInventory service.
/// </summary>
[PluginInterface]
[ServiceManager.ScopedService]
#pragma warning disable SA1015
[ResolveVia<IGameInventory>]
#pragma warning restore SA1015
internal class GameInventoryPluginScoped : IInternalDisposableService, IGameInventory
{
private static readonly ModuleLog Log = new(nameof(GameInventoryPluginScoped));
[ServiceManager.ServiceDependency]
private readonly GameInventory gameInventoryService = Service<GameInventory>.Get();
/// <summary>
/// Initializes a new instance of the <see cref="GameInventoryPluginScoped"/> class.
/// </summary>
public GameInventoryPluginScoped() => this.gameInventoryService.Subscribe(this);
/// <inheritdoc/>
public event IGameInventory.InventoryChangelogDelegate? InventoryChanged;
/// <inheritdoc/>
public event IGameInventory.InventoryChangelogDelegate? InventoryChangedRaw;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate? ItemAdded;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate? ItemRemoved;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate? ItemChanged;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate? ItemMoved;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate? ItemSplit;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate? ItemMerged;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate<InventoryItemAddedArgs>? ItemAddedExplicit;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate<InventoryItemRemovedArgs>? ItemRemovedExplicit;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate<InventoryItemChangedArgs>? ItemChangedExplicit;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate<InventoryItemMovedArgs>? ItemMovedExplicit;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate<InventoryItemSplitArgs>? ItemSplitExplicit;
/// <inheritdoc/>
public event IGameInventory.InventoryChangedDelegate<InventoryItemMergedArgs>? ItemMergedExplicit;
/// <inheritdoc/>
public ReadOnlySpan<GameInventoryItem> GetInventoryItems(GameInventoryType type) => GameInventoryItem.GetReadOnlySpanOfInventory(type);
/// <inheritdoc/>
void IInternalDisposableService.DisposeService()
{
this.gameInventoryService.Unsubscribe(this);
this.InventoryChanged = null;
this.InventoryChangedRaw = null;
this.ItemAdded = null;
this.ItemRemoved = null;
this.ItemChanged = null;
this.ItemMoved = null;
this.ItemSplit = null;
this.ItemMerged = null;
this.ItemAddedExplicit = null;
this.ItemRemovedExplicit = null;
this.ItemChangedExplicit = null;
this.ItemMovedExplicit = null;
this.ItemSplitExplicit = null;
this.ItemMergedExplicit = null;
}
/// <summary>
/// Invoke <see cref="InventoryChanged"/>.
/// </summary>
/// <param name="data">The data.</param>
internal void InvokeChanged(IReadOnlyCollection<InventoryEventArgs> data)
{
try
{
this.InventoryChanged?.Invoke(data);
}
catch (Exception e)
{
Log.Error(
e,
"[{plugin}] Exception during {argType} callback",
Service<PluginManager>.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)",
nameof(this.InventoryChanged));
}
}
/// <summary>
/// Invoke <see cref="InventoryChangedRaw"/>.
/// </summary>
/// <param name="data">The data.</param>
internal void InvokeChangedRaw(IReadOnlyCollection<InventoryEventArgs> data)
{
try
{
this.InventoryChangedRaw?.Invoke(data);
}
catch (Exception e)
{
Log.Error(
e,
"[{plugin}] Exception during {argType} callback",
Service<PluginManager>.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)",
nameof(this.InventoryChangedRaw));
}
}
// Note below: using List<T> instead of IEnumerable<T>, since List<T> has a specialized lightweight enumerator.
/// <summary>
/// Invoke the appropriate event handler.
/// </summary>
/// <param name="events">The data.</param>
internal void Invoke(List<InventoryItemAddedArgs> events) =>
Invoke(this.ItemAdded, this.ItemAddedExplicit, events);
/// <summary>
/// Invoke the appropriate event handler.
/// </summary>
/// <param name="events">The data.</param>
internal void Invoke(List<InventoryItemRemovedArgs> events) =>
Invoke(this.ItemRemoved, this.ItemRemovedExplicit, events);
/// <summary>
/// Invoke the appropriate event handler.
/// </summary>
/// <param name="events">The data.</param>
internal void Invoke(List<InventoryItemChangedArgs> events) =>
Invoke(this.ItemChanged, this.ItemChangedExplicit, events);
/// <summary>
/// Invoke the appropriate event handler.
/// </summary>
/// <param name="events">The data.</param>
internal void Invoke(List<InventoryItemMovedArgs> events) =>
Invoke(this.ItemMoved, this.ItemMovedExplicit, events);
/// <summary>
/// Invoke the appropriate event handler.
/// </summary>
/// <param name="events">The data.</param>
internal void Invoke(List<InventoryItemSplitArgs> events) =>
Invoke(this.ItemSplit, this.ItemSplitExplicit, events);
/// <summary>
/// Invoke the appropriate event handler.
/// </summary>
/// <param name="events">The data.</param>
internal void Invoke(List<InventoryItemMergedArgs> events) =>
Invoke(this.ItemMerged, this.ItemMergedExplicit, events);
private static void Invoke<T>(
IGameInventory.InventoryChangedDelegate? cb,
IGameInventory.InventoryChangedDelegate<T>? cbt,
List<T> events) where T : InventoryEventArgs
{
foreach (var evt in events)
{
try
{
cb?.Invoke(evt.Type, evt);
}
catch (Exception e)
{
Log.Error(
e,
"[{plugin}] Exception during untyped callback for {evt}",
Service<PluginManager>.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)",
evt);
}
try
{
cbt?.Invoke(evt);
}
catch (Exception e)
{
Log.Error(
e,
"[{plugin}] Exception during typed callback for {evt}",
Service<PluginManager>.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)",
evt);
}
}
}
}