diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs new file mode 100644 index 000000000..0d61e34d8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs @@ -0,0 +1,48 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class CagTreeBuilderTests +{ + [Fact] + public void BuildTree_creates_nested_categories_and_resolves_types() + { + var index = new ObjectIndexFile + { + Objects = + [ + new ObjectIndexEntry + { + Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", + Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 + } + ] + }; + + var root = CAGLoader.BuildTree(index); + + var items = Assert.IsType(FindChild(root, "Items")); + var weapons = Assert.IsType(FindChild(items, "Weapons")); + var swords = Assert.IsType(FindChild(weapons, "Swords")); + + var leaf = Assert.IsType(swords.Nodes[0]); + Assert.Equal(typeof(Katana), leaf.Type); + Assert.Equal(0x13FF, leaf.ItemID); + } + + private static CAGNode FindChild(CAGCategory parent, string title) + { + foreach (var node in parent.Nodes) + { + if (node.Title == title) + { + return node; + } + } + + throw new Xunit.Sdk.XunitException($"No child '{title}'."); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs index cbded692c..f214ee063 100644 --- a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs +++ b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs @@ -29,6 +29,151 @@ public static class CAGLoader private static readonly ILogger logger = LogFactory.GetLogger(typeof(CAGLoader)); public static CAGCategory Load() + { + var indexPath = Path.Combine(Core.BaseDirectory, "Data/objects/index.json"); + + if (!File.Exists(indexPath)) + { + logger.Warning("objects/index.json missing — run [GenObjects. Falling back to live categorization load."); + return LoadLegacy(); + } + + var index = JsonConfig.Deserialize(indexPath); + if (index?.Objects == null) + { + throw new JsonException($"Failed to deserialize {indexPath}."); + } + + var root = BuildTree(index); + AddFallbackForStaleCache(root, index); + return root; + } + + public static CAGCategory BuildTree(ObjectIndexFile index) + { + var root = new CAGCategory("Add Menu"); + + foreach (var entry in index.Objects) + { + var type = AssemblyHandler.FindTypeByName(entry.Type); + if (type == null) + { + logger.Warning("Cached type {Type} no longer resolves; skipping.", entry.Type); + continue; + } + + var category = NavigateToCategory(root, entry.Category); + AppendObject( + category, + new CAGObject + { + Type = type, + ItemID = entry.ItemID, + Hue = entry.Hue == 0 ? null : entry.Hue, + Parent = category + } + ); + } + + return root; + } + + private static CAGCategory NavigateToCategory(CAGCategory root, string dotted) + { + var parent = root; + foreach (var name in dotted.Split('.')) + { + var child = FindCategory(parent, name); + if (child == null) + { + child = new CAGCategory(name, parent); + AppendNode(parent, child); + } + + parent = child; + } + + return parent; + } + + private static CAGCategory FindCategory(CAGCategory parent, string title) + { + if (parent.Nodes == null) + { + return null; + } + + foreach (var node in parent.Nodes) + { + if (node is CAGCategory cat && cat.Title == title) + { + return cat; + } + } + + return null; + } + + private static void AppendNode(CAGCategory parent, CAGNode node) + { + var nodes = parent.Nodes ?? []; + var grown = new CAGNode[nodes.Length + 1]; + Array.Copy(nodes, grown, nodes.Length); + grown[^1] = node; + parent.Nodes = grown; + } + + private static void AppendObject(CAGCategory category, CAGObject obj) => AppendNode(category, obj); + + private static void AddFallbackForStaleCache(CAGCategory root, ObjectIndexFile index) + { + var cached = new HashSet(); + foreach (var entry in index.Objects) + { + cached.Add(entry.Type); + } + + var categorizationPath = Path.Combine(Core.BaseDirectory, "Data/categorization.json"); + var categorization = JsonConfig.Deserialize>(categorizationPath); + if (categorization == null) + { + return; + } + + foreach (var cag in categorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type == null || cached.Contains(obj.Type.Name)) + { + continue; + } + + logger.Warning("objects cache stale for {Type} — run [GenObjects.", obj.Type.Name); + try + { + var lean = ObjectIntrospection.ExtractLean(obj.Type); + var category = NavigateToCategory(root, cag.Category); + AppendObject( + category, + new CAGObject + { + Type = obj.Type, + ItemID = lean.ItemID, + Hue = lean.Hue == 0 ? null : lean.Hue, + Parent = category + } + ); + } + catch (Exception ex) + { + logger.Warning(ex, "Failed live fallback for {Type}.", obj.Type.Name); + } + } + } + } + + private static CAGCategory LoadLegacy() { var root = new CAGCategory("Add Menu"); var path = Path.Combine(Core.BaseDirectory, "Data/categorization.json");