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/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/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); + } +}