feat(objects): ObjectCacheGenerator core + GenObjects command

Wraps introspection, categorization sync, and cache-building into a
single file-I/O-free Generate() so the full pipeline is testable
without a shard; GenObjects is a thin command that adds file I/O and
operator messaging on top.

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

View file

@ -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<Type> { typeof(Runebook), typeof(Katana) };
var categorization = new List<CAGJson>(); // 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);
}
}

View file

@ -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<List<CAGJson>>(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));
}
}
}

View file

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using Server.Logging;
namespace Server.Commands;
public sealed record ObjectCacheResult(
ObjectIndexFile Index,
Dictionary<string, Dictionary<string, ObjectDetail>> Chunks,
List<CAGJson> UpdatedCategorization,
SyncReport Report
);
public static class ObjectCacheGenerator
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ObjectCacheGenerator));
public static ObjectCacheResult Generate(List<CAGJson> categorization, IReadOnlyList<Type> discovered)
{
var (updatedCategorization, report) = CategorizationSync.Reconcile(categorization, discovered);
var categoryByType = new Dictionary<Type, string>();
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<ExtractedObject>();
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);
}
}