Compare commits
13 commits
main
...
feat/objec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b8aff6b71 | ||
|
|
bff08c69d2 | ||
|
|
d72313b76b | ||
|
|
06001a6ea5 | ||
|
|
0ac505d7b6 | ||
|
|
a55d83fc21 | ||
|
|
8f20106aec | ||
|
|
ee0c8c256d | ||
|
|
c71bc4bcd5 | ||
|
|
685fbc146e | ||
|
|
95d57afdec | ||
|
|
d02cb56871 | ||
|
|
d8acde2b0a |
18 changed files with 1134 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
56
Projects/UOContent/Commands/GenObjects.cs
Normal file
56
Projects/UOContent/Commands/GenObjects.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
93
Projects/UOContent/Commands/Object Creation/ObjectDocs.cs
Normal file
93
Projects/UOContent/Commands/Object Creation/ObjectDocs.cs
Normal 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; } = [];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue