This commit is contained in:
Kamron Batman 2026-08-24 08:13:30 -05:00 committed by GitHub
commit 5cfa99e800
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1134 additions and 0 deletions

View file

@ -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<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);
}
[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<CAGObject>().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;
}
}

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,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);
}
}

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,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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -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<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");
var pending = new Dictionary<CAGCategory, List<CAGObject>>();
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<CAGObject>();
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<CAGNode> 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<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");

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

View file

@ -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<CtorDoc> Ctors,
List<PropertyDoc> Properties,
List<OplLine> Opl,
string BaseType
);
public static class ObjectCacheBuilder
{
public static (ObjectIndexFile index, Dictionary<string, Dictionary<string, ObjectDetail>> chunks) Build(
IReadOnlyList<ExtractedObject> objects, string generatedUtc
)
{
var index = new ObjectIndexFile { GeneratedUtc = generatedUtc };
var chunks = new Dictionary<string, Dictionary<string, ObjectDetail>>();
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<string, ObjectDetail>();
}
chunk[obj.Type.Name] = new ObjectDetail
{
BaseType = obj.BaseType,
Ctors = obj.Ctors,
Properties = obj.Properties,
Opl = obj.Opl
};
}
return (index, chunks);
}
}

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

View file

@ -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<ObjectIndexEntry> 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<ParamDoc> 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<CtorDoc> Ctors { get; set; } = [];
[JsonPropertyName("properties")] public List<PropertyDoc> Properties { get; set; } = [];
[JsonPropertyName("opl")] public List<OplLine> Opl { get; set; } = [];
}

View file

@ -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<Item>();
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<Mobile>();
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<CtorDoc> ExtractCtors(Type type)
{
var docs = new List<CtorDoc>();
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<PropertyDoc> ExtractProperties(Type type)
{
var docs = new List<PropertyDoc>();
var props = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
foreach (var p in props)
{
var attr = p.GetCustomAttribute<CommandPropertyAttribute>(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<OplLine> ExtractOpl(Type type)
{
if (type.IsAssignableTo(typeof(Item)))
{
var item = type.CreateInstance<Item>();
try
{
var opl = new ObjectPropertyList(item);
item.GetProperties(opl);
return DecodeOpl(opl);
}
finally
{
item.Delete();
}
}
if (type.IsAssignableTo(typeof(Mobile)))
{
var m = type.CreateInstance<Mobile>();
try
{
var opl = new ObjectPropertyList(m);
m.GetProperties(opl);
return DecodeOpl(opl);
}
finally
{
m.Delete();
}
}
return [];
}
private static List<OplLine> DecodeOpl(ObjectPropertyList opl)
{
opl.Terminate();
var buffer = opl.Buffer;
var lines = new List<OplLine>();
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<Type> DiscoverConstructibleTypes()
{
var results = new List<Type>();
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;
}
}