refac(objects): CAGLoader consumes index.json cache with live fallback

CAGLoader.Load() now reads Data/objects/index.json (BuildTree) and only
falls back to live type instantiation for entries missing from the
cache (stale-cache warning) or when the index file itself is absent
(LoadLegacy, the original categorization.json live-load, moved verbatim).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-07-19 11:27:46 -07:00
parent 0ac505d7b6
commit 06001a6ea5
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
2 changed files with 193 additions and 0 deletions

View file

@ -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<CAGCategory>(FindChild(root, "Items"));
var weapons = Assert.IsType<CAGCategory>(FindChild(items, "Weapons"));
var swords = Assert.IsType<CAGCategory>(FindChild(weapons, "Swords"));
var leaf = Assert.IsType<CAGObject>(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}'.");
}
}

View file

@ -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<ObjectIndexFile>(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<string>();
foreach (var entry in index.Objects)
{
cached.Add(entry.Type);
}
var categorizationPath = Path.Combine(Core.BaseDirectory, "Data/categorization.json");
var categorization = JsonConfig.Deserialize<List<CAGJson>>(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");