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..4716dd239 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs @@ -0,0 +1,80 @@ +using System.Linq; +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); + } + + [Fact] + public void BuildTree_keeps_multiple_objects_in_one_category() + { + var index = new ObjectIndexFile + { + Objects = + [ + new ObjectIndexEntry { Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 }, + new ObjectIndexEntry { Type = "Longsword", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x0F5E, Hue = 0 } + ] + }; + + var root = CAGLoader.BuildTree(index); + var swords = (CAGCategory)FindNestedCategory(root, "Items", "Weapons", "Swords"); + + var leafTypes = swords.Nodes.OfType().Select(o => o.Type).ToList(); + Assert.Contains(typeof(Katana), leafTypes); + Assert.Contains(typeof(Server.Items.Longsword), leafTypes); + } + + 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}'."); + } + + private static CAGNode FindNestedCategory(CAGCategory root, params string[] titles) + { + CAGNode current = root; + foreach (var title in titles) + { + current = FindChild((CAGCategory)current, title); + } + + return current; + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs new file mode 100644 index 000000000..8fb4a225d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs @@ -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 { Cat("Items.Weapons.Swords", typeof(Katana)) }; + var discovered = new List { 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 { Cat("Items.Weapons.Swords", typeof(Katana)) }; + var discovered = new List { typeof(Runebook) }; + + var (_, report) = CategorizationSync.Reconcile(categorization, discovered); + + Assert.Contains("Katana", report.Orphaned); + Assert.Contains("Runebook", report.Appended); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs new file mode 100644 index 000000000..860822597 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectCacheBuilderTests +{ + [Fact] + public void Build_produces_index_row_and_detail_chunk() + { + var extracted = new ExtractedObject( + typeof(Runebook), + "item", + "Items.Skill Items.Magical", + new LeanMetadata(8901, 0x461, "runebook", 1041267), + [new CtorDoc()], + [new PropertyDoc { Name = "Hue", Type = "int" }], + [new OplLine { Cliloc = 1041267 }], + "Item" + ); + + var (index, chunks) = ObjectCacheBuilder.Build([extracted], "2026-07-19T00:00:00Z"); + + var row = Assert.Single(index.Objects); + Assert.Equal("Runebook", row.Type); + Assert.Equal("items.skill-items.magical", row.Chunk); + Assert.Equal(8901, row.ItemID); + + var chunk = Assert.Contains("items.skill-items.magical", chunks); + Assert.Contains("Runebook", chunk.Keys); + Assert.Equal("Item", chunk["Runebook"].BaseType); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs new file mode 100644 index 000000000..96521bdfd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectCacheGeneratorTests +{ + [Fact] + public void Generate_builds_index_and_chunks_from_extracted_objects() + { + var discovered = new List { typeof(Runebook), typeof(Katana) }; + var categorization = new List(); // empty -> both land in Items.Uncategorized + + var result = ObjectCacheGenerator.Generate(categorization, discovered); + + Assert.Equal(2, result.Index.Objects.Count); + Assert.NotEmpty(result.Chunks); + + var runebook = Assert.Single(result.Index.Objects, o => o.Type == "Runebook"); + Assert.True(runebook.ItemID > 0); + Assert.Equal(1041267, runebook.Cliloc); + Assert.Equal("items.uncategorized", runebook.Chunk); + + Assert.Contains("Runebook", result.Report.Appended); + Assert.Contains("items.uncategorized", result.Chunks.Keys); + Assert.Contains("Runebook", result.Chunks["items.uncategorized"].Keys); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs new file mode 100644 index 000000000..d36f8bb8a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs @@ -0,0 +1,19 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectDiscoveryTests +{ + [Fact] + public void Discover_includes_concrete_constructibles_and_excludes_abstract() + { + var types = ObjectIntrospection.DiscoverConstructibleTypes(); + + Assert.Contains(typeof(Katana), types); + Assert.Contains(typeof(Runebook), types); + Assert.DoesNotContain(typeof(BaseWeapon), types); // abstract + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs new file mode 100644 index 000000000..268ee13e0 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs @@ -0,0 +1,52 @@ +using System.IO; +using Server.Commands; +using Server.Json; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectIndexSerializationTests +{ + [Fact] + public void ObjectIndexFile_round_trips_through_json() + { + var index = new ObjectIndexFile + { + GeneratedUtc = "2026-07-19T00:00:00Z", + Objects = + [ + new ObjectIndexEntry + { + Type = "Katana", + Entity = "item", + Category = "Items.Weapons.Swords", + Chunk = "items.weapons.swords", + ItemID = 8901, + Hue = 0x461, + Name = "katana", + Cliloc = 1041267 + } + ] + }; + + var tempPath = Path.GetTempFileName(); + try + { + JsonConfig.Serialize(tempPath, index); + var roundTripped = JsonConfig.Deserialize(tempPath); + + Assert.NotNull(roundTripped); + var entry = Assert.Single(roundTripped.Objects); + + Assert.Equal("Katana", entry.Type); + Assert.Equal("items.weapons.swords", entry.Chunk); + Assert.Equal(8901, entry.ItemID); + Assert.Equal(0x461, entry.Hue); + Assert.Equal(1041267, entry.Cliloc); + } + finally + { + File.Delete(tempPath); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs new file mode 100644 index 000000000..6c35df2f8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs @@ -0,0 +1,23 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionCtorsTests +{ + [Fact] + public void ExtractCtors_lists_both_constructible_runebook_overloads() + { + // Runebook has [Constructible] Runebook() and [Constructible] Runebook(int maxCharges). + var ctors = ObjectIntrospection.ExtractCtors(typeof(Runebook)); + + Assert.Equal(2, ctors.Count); + Assert.Contains(ctors, c => c.Parameters.Count == 0); + + var parameterized = Assert.Single(ctors, c => c.Parameters.Count == 1); + Assert.Equal("maxCharges", parameterized.Parameters[0].Name); + Assert.Equal("int", parameterized.Parameters[0].Type); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs new file mode 100644 index 000000000..5ddb65278 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs @@ -0,0 +1,31 @@ +using Server.Commands; +using Server.Items; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionLeanTests +{ + [SkippableFact] + public void ExtractLean_reads_item_id_from_a_weapon() + { + // Requires client TileData: ExtractLean clamps itemID > TileData.MaxItemValue to 1, and + // MaxItemValue is 0 when tiledata.mul is absent (CI), so the real 0x13FF only survives with data. + TileDataRequirement.SkipIfMissing(); + + // Katana ctor is base(0x13FF) — era-independent. + var lean = ObjectIntrospection.ExtractLean(typeof(Katana)); + Assert.Equal(0x13FF, lean.ItemID); + } + + [Fact] + public void ExtractLean_reads_hue_and_cliloc_from_a_runebook() + { + // Runebook sets Hue = 0x461 and LabelNumber 1041267 regardless of era. + var lean = ObjectIntrospection.ExtractLean(typeof(Runebook)); + Assert.Equal(0x461, lean.Hue); + Assert.Equal(1041267, lean.Cliloc); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs new file mode 100644 index 000000000..b360a2726 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs @@ -0,0 +1,17 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionOplTests +{ + [Fact] + public void ExtractOpl_captures_the_runebook_name_cliloc() + { + // Runebook.LabelNumber is 1041267 ("runebook") — it appears as an OPL line. + var opl = ObjectIntrospection.ExtractOpl(typeof(Runebook)); + Assert.Contains(opl, line => line.Cliloc == 1041267); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs new file mode 100644 index 000000000..1eba2a5bb --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs @@ -0,0 +1,22 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionPropertiesTests +{ + [Fact] + public void ExtractProperties_includes_inherited_item_command_properties() + { + var props = ObjectIntrospection.ExtractProperties(typeof(Runebook)); + + var hue = Assert.Single(props, p => p.Name == "Hue"); + Assert.Equal("int", hue.Type); + + var lootType = Assert.Single(props, p => p.Name == "LootType"); + Assert.NotNull(lootType.EnumValues); + Assert.Contains("Blessed", lootType.EnumValues); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs new file mode 100644 index 000000000..f24216df8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs @@ -0,0 +1,29 @@ +using System; +using Server.Commands; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectNamingTests +{ + [Theory] + [InlineData("Items.Skill Items.Magical", "items.skill-items.magical")] + [InlineData("Items.Weapons.Swords", "items.weapons.swords")] + [InlineData("Mobiles.Uncategorized", "mobiles.uncategorized")] + public void ChunkKey_lowercases_and_replaces_spaces(string category, string expected) + { + Assert.Equal(expected, ObjectNaming.ChunkKey(category)); + } + + [Theory] + [InlineData(typeof(int), "int")] + [InlineData(typeof(bool), "bool")] + [InlineData(typeof(string), "string")] + [InlineData(typeof(double), "double")] + [InlineData(typeof(int?), "int?")] + [InlineData(typeof(Server.Items.WeaponQuality), "WeaponQuality")] + public void FriendlyTypeName_maps_primitives_and_keeps_enum_names(Type t, string expected) + { + Assert.Equal(expected, ObjectNaming.FriendlyTypeName(t)); + } +} diff --git a/Projects/UOContent/Commands/GenObjects.cs b/Projects/UOContent/Commands/GenObjects.cs new file mode 100644 index 000000000..dfd6903a1 --- /dev/null +++ b/Projects/UOContent/Commands/GenObjects.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.IO; +using Server.Json; +using Server.Logging; + +namespace Server.Commands; + +public static class GenObjects +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenObjects)); + + public static void Configure() + { + CommandSystem.Register("GenObjects", AccessLevel.Developer, GenObjects_OnCommand); + } + + [Usage("GenObjects")] + [Aliases("GenObjWeb")] + [Description("Generates the objects cache (index + detail chunks) and syncs categorization.json.")] + private static void GenObjects_OnCommand(CommandEventArgs e) + { + var baseDir = Core.BaseDirectory; + var categorizationPath = Path.Combine(baseDir, "Data", "categorization.json"); + var categorization = JsonConfig.Deserialize>(categorizationPath) ?? []; + var discovered = ObjectIntrospection.DiscoverConstructibleTypes(); + + var result = ObjectCacheGenerator.Generate(categorization, discovered); + + var objectsDir = Path.Combine(baseDir, "Data", "objects"); + var detailDir = Path.Combine(objectsDir, "detail"); + Directory.CreateDirectory(detailDir); + + JsonConfig.Serialize(Path.Combine(objectsDir, "index.json"), result.Index); + foreach (var (chunkKey, map) in result.Chunks) + { + JsonConfig.Serialize(Path.Combine(detailDir, $"{chunkKey}.json"), map); + } + + JsonConfig.Serialize(categorizationPath, result.UpdatedCategorization); + + e.Mobile.SendMessage( + $"Objects cache written: {result.Index.Objects.Count} objects, {result.Chunks.Count} chunks. " + + $"Appended {result.Report.Appended.Count} to Uncategorized, {result.Report.Orphaned.Count} orphaned." + ); + + if (result.Report.Appended.Count > 0) + { + logger.Information("Appended to Uncategorized: {Types}", string.Join(", ", result.Report.Appended)); + } + + if (result.Report.Orphaned.Count > 0) + { + logger.Warning("Orphaned categorization entries: {Types}", string.Join(", ", result.Report.Orphaned)); + } + } +} diff --git a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs index cbded692c..6b9c37c8b 100644 --- a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs +++ b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs @@ -29,6 +29,175 @@ 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"); + var pending = new Dictionary>(); + + 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); + if (!pending.TryGetValue(category, out var objects)) + { + objects = new List(); + pending[category] = objects; + } + + objects.Add( + new CAGObject + { + Type = type, + ItemID = entry.ItemID, + Hue = entry.Hue == 0 ? null : entry.Hue, + Parent = category + } + ); + } + + foreach (var (category, objects) in pending) + { + AppendNodes(category, objects); + } + + 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 AppendNodes(CAGCategory parent, IReadOnlyList nodes) + { + var existing = parent.Nodes ?? []; + var grown = new CAGNode[existing.Length + nodes.Count]; + Array.Copy(existing, grown, existing.Length); + for (var i = 0; i < nodes.Count; i++) + { + grown[existing.Length + i] = nodes[i]; + } + + parent.Nodes = grown; + } + + 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"); diff --git a/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs new file mode 100644 index 000000000..90b8f0169 --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server.Items; + +namespace Server.Commands; + +public sealed record SyncReport(List Appended, List Orphaned); + +public static class CategorizationSync +{ + public static (List updated, SyncReport report) Reconcile( + List categorization, IReadOnlyList discovered + ) + { + var categorizedTypes = new HashSet(); + foreach (var cag in categorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type != null) + { + categorizedTypes.Add(obj.Type); + } + } + } + + var discoveredSet = new HashSet(discovered); + + var orphaned = categorizedTypes + .Where(t => !discoveredSet.Contains(t)) + .Select(t => t.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + var updated = new List(categorization); + var appended = new List(); + var itemAppend = new List(); + var mobileAppend = new List(); + + 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 updated, string category, List 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(existing.Objects ?? []); + merged.AddRange(toAdd); + updated[updated.IndexOf(existing)] = existing with { Objects = merged.ToArray() }; + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs new file mode 100644 index 000000000..0ce79145f --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Server.Commands; + +public sealed record ExtractedObject( + Type Type, + string Entity, + string Category, + LeanMetadata Lean, + List Ctors, + List Properties, + List Opl, + string BaseType +); + +public static class ObjectCacheBuilder +{ + public static (ObjectIndexFile index, Dictionary> chunks) Build( + IReadOnlyList objects, string generatedUtc + ) + { + var index = new ObjectIndexFile { GeneratedUtc = generatedUtc }; + var chunks = new Dictionary>(); + + foreach (var obj in objects) + { + var chunkKey = ObjectNaming.ChunkKey(obj.Category); + + index.Objects.Add( + new ObjectIndexEntry + { + Type = obj.Type.Name, + Entity = obj.Entity, + Category = obj.Category, + Chunk = chunkKey, + ItemID = obj.Lean.ItemID, + Hue = obj.Lean.Hue, + Name = obj.Lean.Name, + Cliloc = obj.Lean.Cliloc + } + ); + + if (!chunks.TryGetValue(chunkKey, out var chunk)) + { + chunks[chunkKey] = chunk = new Dictionary(); + } + + chunk[obj.Type.Name] = new ObjectDetail + { + BaseType = obj.BaseType, + Ctors = obj.Ctors, + Properties = obj.Properties, + Opl = obj.Opl + }; + } + + return (index, chunks); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs new file mode 100644 index 000000000..1f7db92b6 --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using Server.Logging; + +namespace Server.Commands; + +public sealed record ObjectCacheResult( + ObjectIndexFile Index, + Dictionary> Chunks, + List UpdatedCategorization, + SyncReport Report +); + +public static class ObjectCacheGenerator +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ObjectCacheGenerator)); + + public static ObjectCacheResult Generate(List categorization, IReadOnlyList discovered) + { + var (updatedCategorization, report) = CategorizationSync.Reconcile(categorization, discovered); + + var categoryByType = new Dictionary(); + foreach (var cag in updatedCategorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type != null) + { + categoryByType[obj.Type] = cag.Category; + } + } + } + + var generatedUtc = DateTime.UtcNow.ToString("O"); + var extracted = new List(); + foreach (var type in discovered) + { + try + { + var entity = typeof(Mobile).IsAssignableFrom(type) ? "mobile" : "item"; + var category = categoryByType.TryGetValue(type, out var c) + ? c + : entity == "mobile" ? "Mobiles.Uncategorized" : "Items.Uncategorized"; + + extracted.Add( + new ExtractedObject( + type, + entity, + category, + ObjectIntrospection.ExtractLean(type), + ObjectIntrospection.ExtractCtors(type), + ObjectIntrospection.ExtractProperties(type), + ObjectIntrospection.ExtractOpl(type), + type.BaseType?.Name ?? "object" + ) + ); + } + catch (Exception ex) + { + logger.Warning(ex, "Failed to introspect {Type}; skipping.", type); + } + } + + var (index, chunks) = ObjectCacheBuilder.Build(extracted, generatedUtc); + return new ObjectCacheResult(index, chunks, updatedCategorization, report); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs new file mode 100644 index 000000000..8f18dd8fc --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Server.Commands; + +public static class ObjectNaming +{ + public static string ChunkKey(string category) => category.ToLowerInvariant().Replace(' ', '-'); + + public static string FriendlyTypeName(Type t) + { + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return $"{FriendlyTypeName(Nullable.GetUnderlyingType(t))}?"; + } + + return t switch + { + _ when t == typeof(int) => "int", + _ when t == typeof(uint) => "uint", + _ when t == typeof(bool) => "bool", + _ when t == typeof(string) => "string", + _ when t == typeof(double) => "double", + _ when t == typeof(float) => "float", + _ when t == typeof(long) => "long", + _ when t == typeof(ulong) => "ulong", + _ when t == typeof(short) => "short", + _ when t == typeof(ushort) => "ushort", + _ when t == typeof(byte) => "byte", + _ when t == typeof(sbyte) => "sbyte", + _ when t == typeof(char) => "char", + _ when t == typeof(decimal) => "decimal", + _ => t.Name + }; + } +} + +public sealed class ObjectIndexEntry +{ + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("entity")] public string Entity { get; set; } + [JsonPropertyName("category")] public string Category { get; set; } + [JsonPropertyName("chunk")] public string Chunk { get; set; } + [JsonPropertyName("gfx")] public int ItemID { get; set; } + [JsonPropertyName("hue")] public int Hue { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("cliloc")] public int? Cliloc { get; set; } +} + +public sealed class ObjectIndexFile +{ + [JsonPropertyName("generatedUtc")] public string GeneratedUtc { get; set; } + [JsonPropertyName("objects")] public List Objects { get; set; } = []; +} + +public sealed class ParamDoc +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("default")] public string Default { get; set; } + [JsonPropertyName("isParams")] public bool IsParams { get; set; } +} + +public sealed class CtorDoc +{ + [JsonPropertyName("parameters")] public List Parameters { get; set; } = []; +} + +public sealed class PropertyDoc +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("readLevel")] public string ReadLevel { get; set; } + [JsonPropertyName("writeLevel")] public string WriteLevel { get; set; } + [JsonPropertyName("readOnly")] public bool ReadOnly { get; set; } + [JsonPropertyName("enumValues")] public string[] EnumValues { get; set; } +} + +public sealed class OplLine +{ + [JsonPropertyName("cliloc")] public int Cliloc { get; set; } + [JsonPropertyName("args")] public string Args { get; set; } + [JsonPropertyName("text")] public string Text { get; set; } +} + +public sealed class ObjectDetail +{ + [JsonPropertyName("baseType")] public string BaseType { get; set; } + [JsonPropertyName("ctors")] public List Ctors { get; set; } = []; + [JsonPropertyName("properties")] public List Properties { get; set; } = []; + [JsonPropertyName("opl")] public List Opl { get; set; } = []; +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs new file mode 100644 index 000000000..f0a11b31c --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -0,0 +1,228 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using Server.Items; + +namespace Server.Commands; + +public readonly record struct LeanMetadata(int ItemID, int Hue, string Name, int? Cliloc); + +public static class ObjectIntrospection +{ + public static LeanMetadata ExtractLean(Type type) + { + if (type.IsAssignableTo(typeof(Item))) + { + var item = type.CreateInstance(); + try + { + var itemID = item.ItemID; + if (item is BaseAddon addon && addon.Components.Count == 1) + { + itemID = addon.Components[0].ItemID; + } + + if (itemID > TileData.MaxItemValue) + { + itemID = 1; + } + + var hue = item.Hue & 0x7FFF; + hue = (hue & 0x4000) != 0 ? 0 : hue; + + var cliloc = item.LabelNumber > 0 ? item.LabelNumber : (int?)null; + var name = item.Name ?? (cliloc.HasValue ? Server.Localization.GetText(cliloc.Value) : null); + + return new LeanMetadata(itemID, hue, name, cliloc); + } + finally + { + item.Delete(); + } + } + + if (type.IsAssignableTo(typeof(Mobile))) + { + var m = type.CreateInstance(); + try + { + var itemID = ShrinkTable.Lookup(m, 1); + var hue = m.Hue & 0x7FFF; + hue = (hue & 0x4000) != 0 ? 0 : hue; + return new LeanMetadata(itemID, hue, m.Name, null); + } + finally + { + m.Delete(); + } + } + + throw new ArgumentException($"{type} is neither Item nor Mobile.", nameof(type)); + } + + public static List ExtractCtors(Type type) + { + var docs = new List(); + + foreach (var ctor in type.GetConstructors()) + { + if (!Attributes.IsConstructible(ctor, AccessLevel.Developer)) + { + continue; + } + + var doc = new CtorDoc(); + foreach (var p in ctor.GetParameters()) + { + doc.Parameters.Add( + new ParamDoc + { + Name = p.Name, + Type = ObjectNaming.FriendlyTypeName(p.ParameterType), + Default = p.HasDefaultValue ? p.DefaultValue?.ToString() ?? "null" : null, + IsParams = p.IsDefined(typeof(ParamArrayAttribute), false) + } + ); + } + + docs.Add(doc); + } + + return docs; + } + + public static List ExtractProperties(Type type) + { + var docs = new List(); + + var props = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + foreach (var p in props) + { + var attr = p.GetCustomAttribute(true); + if (attr == null) + { + continue; + } + + var pt = p.PropertyType; + docs.Add( + new PropertyDoc + { + Name = p.Name, + Type = ObjectNaming.FriendlyTypeName(pt), + ReadLevel = attr.ReadLevel.ToString(), + WriteLevel = attr.WriteLevel.ToString(), + ReadOnly = attr.ReadOnly || !p.CanWrite, + EnumValues = pt.IsEnum ? Enum.GetNames(pt) : null + } + ); + } + + return docs; + } + + public static List ExtractOpl(Type type) + { + if (type.IsAssignableTo(typeof(Item))) + { + var item = type.CreateInstance(); + try + { + var opl = new ObjectPropertyList(item); + item.GetProperties(opl); + return DecodeOpl(opl); + } + finally + { + item.Delete(); + } + } + + if (type.IsAssignableTo(typeof(Mobile))) + { + var m = type.CreateInstance(); + try + { + var opl = new ObjectPropertyList(m); + m.GetProperties(opl); + return DecodeOpl(opl); + } + finally + { + m.Delete(); + } + } + + return []; + } + + private static List DecodeOpl(ObjectPropertyList opl) + { + opl.Terminate(); + var buffer = opl.Buffer; + var lines = new List(); + var pos = 15; // fixed OPL header length + + while (pos + 4 <= buffer.Length) + { + var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos)); + pos += 4; + if (cliloc == 0) + { + break; + } + + var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos)); + pos += 2; + var args = byteLen > 0 ? Encoding.Unicode.GetString(buffer, pos, byteLen) : null; + pos += byteLen; + + lines.Add(new OplLine { Cliloc = cliloc, Args = args, Text = Localization.GetText(cliloc) }); + } + + return lines; + } + + public static List DiscoverConstructibleTypes() + { + var results = new List(); + + foreach (var asm in AssemblyHandler.Assemblies) + { + foreach (var type in AssemblyHandler.GetTypeCache(asm).Types) + { + if (type.IsAbstract) + { + continue; + } + + if (!typeof(Item).IsAssignableFrom(type) && !typeof(Mobile).IsAssignableFrom(type)) + { + continue; + } + + if (HasConstructibleCtor(type)) + { + results.Add(type); + } + } + } + + return results; + } + + private static bool HasConstructibleCtor(Type type) + { + foreach (var ctor in type.GetConstructors()) + { + if (Attributes.IsConstructible(ctor, AccessLevel.Developer)) + { + return true; + } + } + + return false; + } +}