feat(objects): CategorizationSync.Reconcile — append Uncategorized + report orphans

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

View file

@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
public class CategorizationSyncTests
{
private static CAGJson Cat(string category, params System.Type[] types) =>
new()
{
Category = category,
Objects = types.Select(t => new CAGObject { Type = t }).ToArray()
};
[Fact]
public void Reconcile_appends_missing_types_to_uncategorized()
{
var categorization = new List<CAGJson> { Cat("Items.Weapons.Swords", typeof(Katana)) };
var discovered = new List<System.Type> { typeof(Katana), typeof(Runebook) };
var (updated, report) = CategorizationSync.Reconcile(categorization, discovered);
Assert.Contains("Runebook", report.Appended);
Assert.Empty(report.Orphaned);
var uncategorized = Assert.Single(updated, c => c.Category == "Items.Uncategorized");
Assert.Contains(uncategorized.Objects, o => o.Type == typeof(Runebook));
}
[Fact]
public void Reconcile_reports_orphans_not_in_discovered()
{
var categorization = new List<CAGJson> { Cat("Items.Weapons.Swords", typeof(Katana)) };
var discovered = new List<System.Type> { typeof(Runebook) };
var (_, report) = CategorizationSync.Reconcile(categorization, discovered);
Assert.Contains("Katana", report.Orphaned);
Assert.Contains("Runebook", report.Appended);
}
}

View file

@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Server.Items;
namespace Server.Commands;
public sealed record SyncReport(List<string> Appended, List<string> Orphaned);
public static class CategorizationSync
{
public static (List<CAGJson> updated, SyncReport report) Reconcile(
List<CAGJson> categorization, IReadOnlyList<Type> discovered
)
{
var categorizedTypes = new HashSet<Type>();
foreach (var cag in categorization)
{
foreach (var obj in cag.Objects ?? [])
{
if (obj.Type != null)
{
categorizedTypes.Add(obj.Type);
}
}
}
var discoveredSet = new HashSet<Type>(discovered);
var orphaned = categorizedTypes
.Where(t => !discoveredSet.Contains(t))
.Select(t => t.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
var updated = new List<CAGJson>(categorization);
var appended = new List<string>();
var itemAppend = new List<CAGObject>();
var mobileAppend = new List<CAGObject>();
foreach (var type in discovered)
{
if (categorizedTypes.Contains(type))
{
continue;
}
appended.Add(type.Name);
var target = typeof(Mobile).IsAssignableFrom(type) ? mobileAppend : itemAppend;
target.Add(new CAGObject { Type = type });
}
AppendUncategorized(updated, "Items.Uncategorized", itemAppend);
AppendUncategorized(updated, "Mobiles.Uncategorized", mobileAppend);
return (updated, new SyncReport(appended, orphaned));
}
private static void AppendUncategorized(List<CAGJson> updated, string category, List<CAGObject> toAdd)
{
if (toAdd.Count == 0)
{
return;
}
var existing = updated.FirstOrDefault(c => c.Category == category);
if (existing == null)
{
updated.Add(new CAGJson { Category = category, Objects = toAdd.ToArray() });
return;
}
var merged = new List<CAGObject>(existing.Objects ?? []);
merged.AddRange(toAdd);
updated[updated.IndexOf(existing)] = existing with { Objects = merged.ToArray() };
}
}