diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs index 9aa9e51..2d0be8f 100644 --- a/AetherBags/Addons/AddonInventoryWindow.cs +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -125,6 +125,7 @@ public class AddonInventoryWindow : NativeAddon private void RefreshCategoriesCore(bool autosize) { _footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString(); + _footerNode.RefreshCurrencies(); string filter = _searchInputNode.SearchString.ExtractText(); IReadOnlyList categories = InventoryState.GetInventoryItemCategories(filter); @@ -141,12 +142,9 @@ public class AddonInventoryWindow : NativeAddon node.CategorizedInventory = data; node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine); }, - createNodeMethod: _ => + createNodeMethod: _ => new InventoryCategoryNode { - return new InventoryCategoryNode - { - Size = ContentSize with { Y = 120 }, - }; + Size = ContentSize with { Y = 120 }, }); WireHoverHandlers(); diff --git a/AetherBags/Configuration/CategorySettings.cs b/AetherBags/Configuration/CategorySettings.cs new file mode 100644 index 0000000..e4edd15 --- /dev/null +++ b/AetherBags/Configuration/CategorySettings.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using KamiToolKit.Classes; + +namespace AetherBags.Configuration; + +public class CategorySettings +{ + public bool GameCategoriesEnabled { get; set; } = true; + public bool UserCategoriesEnabled { get; set; } = true; + + public List UserCategories { get; set; } = new(); +} + +public class UserCategoryDefinition +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Name { get; set; } = "New Category"; + public string Description { get; set; } = string.Empty; + + public int Order { get; set; } + public int Priority { get; set; } = 100; + public Vector4 Color { get; set; } = ColorHelper.GetColor(50); + + public CategoryRuleSet Rules { get; set; } = new(); +} + +public class CategoryRuleSet +{ + public List AllowedItemIds { get; set; } = new(); + public List AllowedItemNamePatterns { get; set; } = new(); + public List AllowedUiCategoryIds { get; set; } = new(); + public List AllowedRarities { get; set; } = new(); + + public RangeFilter ItemLevel { get; set; } = new() { Enabled = false, Min = 0, Max = 2000 }; + public RangeFilter VendorPrice { get; set; } = new() { Enabled = false, Min = 0, Max = 9_999_999 }; +} + +public class RangeFilter where T : struct, IComparable +{ + public bool Enabled { get; set; } + public T Min { get; set; } + public T Max { get; set; } +} \ No newline at end of file diff --git a/AetherBags/Configuration/Import/SortaKindaCategory.cs b/AetherBags/Configuration/Import/SortaKindaCategory.cs new file mode 100644 index 0000000..a3a3c97 --- /dev/null +++ b/AetherBags/Configuration/Import/SortaKindaCategory.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AetherBags.Configuration.Import; + +// Possible Mapping: +// Index -> Order +// Color/Id/Name +// AllowedItemNames -> AllowedItemNamePatterns +// AllowedItemTypes -> AllowedUiCategoryIds +// AllowedItemRarities -> AllowedRarities +// ItemLevelFilter / VendorPriceFilter -> RangeFilter + +public sealed class SortaKindaCategory +{ + public Vector4 Color { get; set; } + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public int Index { get; set; } + + public List AllowedItemNames { get; set; } = new(); + public List AllowedItemTypes { get; set; } = new(); + public List AllowedItemRarities { get; set; } = new(); + + public ExternalRangeFilterDto ItemLevelFilter { get; set; } = new(); + public ExternalRangeFilterDto VendorPriceFilter { get; set; } = new(); + + public int Direction { get; set; } + public int FillMode { get; set; } + public int SortMode { get; set; } +} + +public sealed class ExternalRangeFilterDto where T : struct +{ + public bool Enable { get; set; } + public string Label { get; set; } = string.Empty; + public T MinValue { get; set; } + public T MaxValue { get; set; } +} \ No newline at end of file diff --git a/AetherBags/Configuration/SystemConfiguration.cs b/AetherBags/Configuration/SystemConfiguration.cs index c930b73..5cc8251 100644 --- a/AetherBags/Configuration/SystemConfiguration.cs +++ b/AetherBags/Configuration/SystemConfiguration.cs @@ -8,4 +8,5 @@ public class SystemConfiguration public const string FileName = "AetherBags.json"; public CurrencySettings Currency { get; set; } = new(); + public CategorySettings Categories { get; set; } = new(); } \ No newline at end of file diff --git a/AetherBags/Inventory/InventoryState.cs b/AetherBags/Inventory/InventoryState.cs index 553a0e3..5df7996 100644 --- a/AetherBags/Inventory/InventoryState.cs +++ b/AetherBags/Inventory/InventoryState.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using AetherBags.Configuration; namespace AetherBags.Inventory; @@ -57,23 +58,38 @@ public static unsafe class InventoryState private static readonly List RemoveKeysScratch = new(capacity: 256); + private const uint UserCategoryKeyFlag = 0x8000_0000; + + private static uint MakeUserCategoryKey(int order) + => UserCategoryKeyFlag | (uint)(order & 0x7FFF_FFFF); + + private static bool IsUserCategoryKey(uint key) + => (key & UserCategoryKeyFlag) != 0; + public static bool Contains(this IReadOnlyCollection inventoryTypes, GameInventoryType type) => inventoryTypes.Contains((InventoryType)type); public static void RefreshFromGame() { - InventoryManager* mgr = InventoryManager.Instance(); - if (mgr == null) + InventoryManager* inventoryManager = InventoryManager.Instance(); + if (inventoryManager == null) { ClearAll(); return; } + var config = System.Config; + + bool userCategoriesEnabled = config.Categories.UserCategoriesEnabled; + bool gameCategoriesEnabled = config.Categories.GameCategoriesEnabled; + + List userCategories = config.Categories.UserCategories; + AggByItemId.Clear(); - for (int invIndex = 0; invIndex < BagInventories.Length; invIndex++) + for (int inventoryIndex = 0; inventoryIndex < BagInventories.Length; inventoryIndex++) { - var container = mgr->GetInventoryContainer(BagInventories[invIndex]); + var container = inventoryManager->GetInventoryContainer(BagInventories[inventoryIndex]); if (container == null) continue; @@ -85,26 +101,26 @@ public static unsafe class InventoryState if (id == 0) continue; - int qty = item.Quantity; + int quantity = item.Quantity; if (AggByItemId.TryGetValue(id, out AggregatedItem agg)) { - agg.Total += qty; + agg.Total += quantity; AggByItemId[id] = agg; } else { - AggByItemId.Add(id, new AggregatedItem { First = item, Total = qty }); + AggByItemId.Add(id, new AggregatedItem { First = item, Total = quantity }); } } } foreach (var kvp in BucketsByKey) { - CategoryBucket b = kvp.Value; - b.Used = false; - b.Items.Clear(); - b.FilteredItems.Clear(); + CategoryBucket bucket = kvp.Value; + bucket.Used = false; + bucket.Items.Clear(); + bucket.FilteredItems.Clear(); } foreach (var kvp in AggByItemId) @@ -126,27 +142,135 @@ public static unsafe class InventoryState info.Item = agg.First; info.ItemCount = agg.Total; } + } - uint catKey = info.UiCategory.RowId; + // Bucket by user category + HashSet claimedItemIds = new(capacity: ItemInfoByItemId.Count); - if (!BucketsByKey.TryGetValue(catKey, out CategoryBucket? bucket)) + if (userCategoriesEnabled && userCategories.Count > 0) + { + for (int c = 0; c < userCategories.Count; c++) { - bucket = new CategoryBucket + UserCategoryDefinition category = userCategories[c]; + uint key = MakeUserCategoryKey(category.Order); + + if (!BucketsByKey.TryGetValue(key, out CategoryBucket? bucket)) { - Key = catKey, - Category = GetCategoryInfoForKeyCached(catKey, info), + bucket = new CategoryBucket + { + Key = key, + Category = new CategoryInfo + { + Name = category.Name, + Description = category.Description, + Color = category.Color, + }, + Items = new List(capacity: 16), + FilteredItems = new List(capacity: 16), + Used = true, + }; + BucketsByKey.Add(key, bucket); + } + else + { + bucket.Used = true; + bucket.Category.Name = category.Name; + bucket.Category.Description = category.Description; + bucket.Category.Color = category.Color; + } + + foreach (var itemKvp in ItemInfoByItemId) + { + ItemInfo item = itemKvp.Value; + + if (UserCategoryMatcher.Matches(item, category)) + { + bucket.Items.Add(item); + claimedItemIds.Add(item.Item.ItemId); + } + } + + if (bucket.Items.Count == 0) + bucket.Used = false; + } + } + + // Game category bucket + if (gameCategoriesEnabled) + { + foreach (var itemKvp in ItemInfoByItemId) + { + ItemInfo info = itemKvp.Value; + + if (userCategoriesEnabled && claimedItemIds.Contains(info.Item.ItemId)) + continue; + + uint categoryKey = info.UiCategory.RowId; + + if (!BucketsByKey.TryGetValue(categoryKey, out CategoryBucket? bucket)) + { + bucket = new CategoryBucket + { + Key = categoryKey, + Category = GetCategoryInfoForKeyCached(categoryKey, info), + Items = new List(capacity: 16), + FilteredItems = new List(capacity: 16), + Used = true, + }; + BucketsByKey.Add(categoryKey, bucket); + } + else + { + bucket.Used = true; + } + + bucket.Items.Add(info); + } + } + + // Unclaimed items + if (!gameCategoriesEnabled) + { + if (!BucketsByKey.TryGetValue(0u, out CategoryBucket? miscBucket)) + { + CategoryInfo miscInfo; + if (ItemInfoByItemId.Count > 0) + { + var sample = ItemInfoByItemId.Values.First(); + miscInfo = GetCategoryInfoForKeyCached(0u, sample); + } + else + { + miscInfo = new CategoryInfo { Name = "Misc", Description = "Uncategorized items" }; + } + + miscBucket = new CategoryBucket + { + Key = 0u, + Category = miscInfo, Items = new List(capacity: 16), FilteredItems = new List(capacity: 16), Used = true, }; - BucketsByKey.Add(catKey, bucket); + BucketsByKey.Add(0u, miscBucket); } else { - bucket.Used = true; + miscBucket.Used = true; } - bucket.Items.Add(info); + foreach (var itemKvp in ItemInfoByItemId) + { + ItemInfo info = itemKvp.Value; + + if (userCategoriesEnabled && claimedItemIds.Contains(info.Item.ItemId)) + continue; + + miscBucket.Items.Add(info); + } + + if (miscBucket.Items.Count == 0) + miscBucket.Used = false; } if (ItemInfoByItemId.Count != AggByItemId.Count) @@ -176,7 +300,13 @@ public static unsafe class InventoryState SortedCategoryKeys.Add(bucket.Key); } - SortedCategoryKeys.Sort(); + SortedCategoryKeys.Sort((a, b) => + { + bool au = IsUserCategoryKey(a); + bool bu = IsUserCategoryKey(b); + if (au != bu) return au ? -1 : 1; + return a.CompareTo(b); + }); AllCategories.Clear(); AllCategories.Capacity = Math.Max(AllCategories.Capacity, SortedCategoryKeys.Count); @@ -316,7 +446,6 @@ public static unsafe class InventoryState isCapped = weeklyAcquired >= weeklyLimit; } - Services.Logger.Info($"Currency {currencyItem.ItemId} amount: {amount}, max: {maxAmount}"); return new CurrencyInfo { Amount = amount, @@ -389,10 +518,15 @@ public static unsafe class InventoryState { public static readonly ItemCountDescComparer Instance = new(); - public int Compare(ItemInfo x, ItemInfo y) + public int Compare(ItemInfo? x, ItemInfo? y) { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return 1; // nulls last + if (y is null) return -1; + int a = x.ItemCount; int b = y.ItemCount; + if (a > b) return -1; if (a < b) return 1; return 0; diff --git a/AetherBags/Inventory/ItemInfo.cs b/AetherBags/Inventory/ItemInfo.cs index 99978f5..80ac4dc 100644 --- a/AetherBags/Inventory/ItemInfo.cs +++ b/AetherBags/Inventory/ItemInfo.cs @@ -43,6 +43,7 @@ public sealed class ItemInfo : IEquatable public int Level => Row.LevelEquip; public int ItemLevel => (int)Row.LevelItem.RowId; public int Rarity => Row.Rarity; + public uint VendorPrice => Row.PriceLow; public RowRef UiCategory => Row.ItemUICategory; diff --git a/AetherBags/Inventory/UserCategoryMatcher.cs b/AetherBags/Inventory/UserCategoryMatcher.cs new file mode 100644 index 0000000..35b421e --- /dev/null +++ b/AetherBags/Inventory/UserCategoryMatcher.cs @@ -0,0 +1,66 @@ +using AetherBags.Configuration; +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace AetherBags.Inventory; + +internal static class UserCategoryMatcher +{ + public static bool Matches(ItemInfo item, UserCategoryDefinition userCategory) + { + var rules = userCategory.Rules; + + if (rules.AllowedUiCategoryIds.Count > 0) + { + uint uiCategoryId = item.UiCategory.RowId; + if (!rules.AllowedUiCategoryIds.Contains(uiCategoryId)) + return false; + } + + if (rules.AllowedItemIds.Count > 0 && !rules.AllowedItemIds.Contains(item.Item.ItemId)) + return false; + + if (rules.AllowedRarities.Count > 0 && !rules.AllowedRarities.Contains(item.Rarity)) + return false; + + if (rules.ItemLevel.Enabled && !InRange(item.ItemLevel, rules.ItemLevel.Min, rules.ItemLevel.Max)) + return false; + + if (rules.VendorPrice.Enabled && !InRange(item.VendorPrice, rules.VendorPrice.Min, rules.VendorPrice.Max)) + return false; + + if (rules.AllowedItemNamePatterns.Count > 0) + { + bool any = false; + for (int i = 0; i < rules.AllowedItemNamePatterns.Count; i++) + { + string pattern = rules.AllowedItemNamePatterns[i]; + if (string.IsNullOrWhiteSpace(pattern)) + continue; + + // Treat patterns as regex for now. + try + { + if (Regex.IsMatch(item.Name, pattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) + { + any = true; + break; + } + } + catch + { + // Invalid regex: ignore it. + } + } + + if (!any) + return false; + } + + return true; + } + + private static bool InRange(T value, T min, T max) where T : struct, IComparable + => value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0; +} \ No newline at end of file diff --git a/AetherBags/Nodes/CurrencyListNode.cs b/AetherBags/Nodes/CurrencyListNode.cs index f465a74..c49f338 100644 --- a/AetherBags/Nodes/CurrencyListNode.cs +++ b/AetherBags/Nodes/CurrencyListNode.cs @@ -6,9 +6,5 @@ namespace AetherBags.Nodes; public class CurrencyListNode : HorizontalListNode { - public List CurrencyInfoList - { - get; - set; - } + public List? CurrencyInfoList { get; set; } } \ No newline at end of file