Implement basic usercategories

This commit is contained in:
Zeffuro
2025-12-21 14:07:00 +01:00
parent 16f9311a13
commit bada2bdc8a
8 changed files with 312 additions and 32 deletions
+3 -5
View File
@@ -125,6 +125,7 @@ public class AddonInventoryWindow : NativeAddon
private void RefreshCategoriesCore(bool autosize) private void RefreshCategoriesCore(bool autosize)
{ {
_footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString(); _footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString();
_footerNode.RefreshCurrencies();
string filter = _searchInputNode.SearchString.ExtractText(); string filter = _searchInputNode.SearchString.ExtractText();
IReadOnlyList<CategorizedInventory> categories = InventoryState.GetInventoryItemCategories(filter); IReadOnlyList<CategorizedInventory> categories = InventoryState.GetInventoryItemCategories(filter);
@@ -141,12 +142,9 @@ public class AddonInventoryWindow : NativeAddon
node.CategorizedInventory = data; node.CategorizedInventory = data;
node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine); 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(); WireHoverHandlers();
@@ -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<UserCategoryDefinition> 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<uint> AllowedItemIds { get; set; } = new();
public List<string> AllowedItemNamePatterns { get; set; } = new();
public List<uint> AllowedUiCategoryIds { get; set; } = new();
public List<int> AllowedRarities { get; set; } = new();
public RangeFilter<int> ItemLevel { get; set; } = new() { Enabled = false, Min = 0, Max = 2000 };
public RangeFilter<uint> VendorPrice { get; set; } = new() { Enabled = false, Min = 0, Max = 9_999_999 };
}
public class RangeFilter<T> where T : struct, IComparable<T>
{
public bool Enabled { get; set; }
public T Min { get; set; }
public T Max { get; set; }
}
@@ -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<string> AllowedItemNames { get; set; } = new();
public List<uint> AllowedItemTypes { get; set; } = new();
public List<int> AllowedItemRarities { get; set; } = new();
public ExternalRangeFilterDto<int> ItemLevelFilter { get; set; } = new();
public ExternalRangeFilterDto<uint> VendorPriceFilter { get; set; } = new();
public int Direction { get; set; }
public int FillMode { get; set; }
public int SortMode { get; set; }
}
public sealed class ExternalRangeFilterDto<T> 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; }
}
@@ -8,4 +8,5 @@ public class SystemConfiguration
public const string FileName = "AetherBags.json"; public const string FileName = "AetherBags.json";
public CurrencySettings Currency { get; set; } = new(); public CurrencySettings Currency { get; set; } = new();
public CategorySettings Categories { get; set; } = new();
} }
+156 -22
View File
@@ -6,6 +6,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using AetherBags.Configuration;
namespace AetherBags.Inventory; namespace AetherBags.Inventory;
@@ -57,23 +58,38 @@ public static unsafe class InventoryState
private static readonly List<uint> RemoveKeysScratch = new(capacity: 256); private static readonly List<uint> 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<InventoryType> inventoryTypes, GameInventoryType type) public static bool Contains(this IReadOnlyCollection<InventoryType> inventoryTypes, GameInventoryType type)
=> inventoryTypes.Contains((InventoryType)type); => inventoryTypes.Contains((InventoryType)type);
public static void RefreshFromGame() public static void RefreshFromGame()
{ {
InventoryManager* mgr = InventoryManager.Instance(); InventoryManager* inventoryManager = InventoryManager.Instance();
if (mgr == null) if (inventoryManager == null)
{ {
ClearAll(); ClearAll();
return; return;
} }
var config = System.Config;
bool userCategoriesEnabled = config.Categories.UserCategoriesEnabled;
bool gameCategoriesEnabled = config.Categories.GameCategoriesEnabled;
List<UserCategoryDefinition> userCategories = config.Categories.UserCategories;
AggByItemId.Clear(); 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) if (container == null)
continue; continue;
@@ -85,26 +101,26 @@ public static unsafe class InventoryState
if (id == 0) if (id == 0)
continue; continue;
int qty = item.Quantity; int quantity = item.Quantity;
if (AggByItemId.TryGetValue(id, out AggregatedItem agg)) if (AggByItemId.TryGetValue(id, out AggregatedItem agg))
{ {
agg.Total += qty; agg.Total += quantity;
AggByItemId[id] = agg; AggByItemId[id] = agg;
} }
else else
{ {
AggByItemId.Add(id, new AggregatedItem { First = item, Total = qty }); AggByItemId.Add(id, new AggregatedItem { First = item, Total = quantity });
} }
} }
} }
foreach (var kvp in BucketsByKey) foreach (var kvp in BucketsByKey)
{ {
CategoryBucket b = kvp.Value; CategoryBucket bucket = kvp.Value;
b.Used = false; bucket.Used = false;
b.Items.Clear(); bucket.Items.Clear();
b.FilteredItems.Clear(); bucket.FilteredItems.Clear();
} }
foreach (var kvp in AggByItemId) foreach (var kvp in AggByItemId)
@@ -126,27 +142,135 @@ public static unsafe class InventoryState
info.Item = agg.First; info.Item = agg.First;
info.ItemCount = agg.Total; info.ItemCount = agg.Total;
} }
}
uint catKey = info.UiCategory.RowId; // Bucket by user category
HashSet<uint> 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, bucket = new CategoryBucket
Category = GetCategoryInfoForKeyCached(catKey, info), {
Key = key,
Category = new CategoryInfo
{
Name = category.Name,
Description = category.Description,
Color = category.Color,
},
Items = new List<ItemInfo>(capacity: 16),
FilteredItems = new List<ItemInfo>(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<ItemInfo>(capacity: 16),
FilteredItems = new List<ItemInfo>(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<ItemInfo>(capacity: 16), Items = new List<ItemInfo>(capacity: 16),
FilteredItems = new List<ItemInfo>(capacity: 16), FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true, Used = true,
}; };
BucketsByKey.Add(catKey, bucket); BucketsByKey.Add(0u, miscBucket);
} }
else 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) if (ItemInfoByItemId.Count != AggByItemId.Count)
@@ -176,7 +300,13 @@ public static unsafe class InventoryState
SortedCategoryKeys.Add(bucket.Key); 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.Clear();
AllCategories.Capacity = Math.Max(AllCategories.Capacity, SortedCategoryKeys.Count); AllCategories.Capacity = Math.Max(AllCategories.Capacity, SortedCategoryKeys.Count);
@@ -316,7 +446,6 @@ public static unsafe class InventoryState
isCapped = weeklyAcquired >= weeklyLimit; isCapped = weeklyAcquired >= weeklyLimit;
} }
Services.Logger.Info($"Currency {currencyItem.ItemId} amount: {amount}, max: {maxAmount}");
return new CurrencyInfo return new CurrencyInfo
{ {
Amount = amount, Amount = amount,
@@ -389,10 +518,15 @@ public static unsafe class InventoryState
{ {
public static readonly ItemCountDescComparer Instance = new(); 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 a = x.ItemCount;
int b = y.ItemCount; int b = y.ItemCount;
if (a > b) return -1; if (a > b) return -1;
if (a < b) return 1; if (a < b) return 1;
return 0; return 0;
+1
View File
@@ -43,6 +43,7 @@ public sealed class ItemInfo : IEquatable<ItemInfo>
public int Level => Row.LevelEquip; public int Level => Row.LevelEquip;
public int ItemLevel => (int)Row.LevelItem.RowId; public int ItemLevel => (int)Row.LevelItem.RowId;
public int Rarity => Row.Rarity; public int Rarity => Row.Rarity;
public uint VendorPrice => Row.PriceLow;
public RowRef<ItemUICategory> UiCategory => Row.ItemUICategory; public RowRef<ItemUICategory> UiCategory => Row.ItemUICategory;
@@ -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>(T value, T min, T max) where T : struct, IComparable<T>
=> value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0;
}
+1 -5
View File
@@ -6,9 +6,5 @@ namespace AetherBags.Nodes;
public class CurrencyListNode : HorizontalListNode public class CurrencyListNode : HorizontalListNode
{ {
public List<CurrencyInfo> CurrencyInfoList public List<CurrencyInfo>? CurrencyInfoList { get; set; }
{
get;
set;
}
} }