ModernUO/Projects/UOContent/Commands/AddonGenerator.cs
Kamron Batman 8bd1b3dc28
fix: AddonGenerator produces broken/incomplete addon output (#2517)
## Summary

`[AddonGen` currently produces addon scripts that **do not compile**, plus a few
gather-logic and UI bugs. This fixes all of them.

## Compile-breaking (verified)

Every generated addon failed to build because of the item-component emission path:

- **Trailing comma + missing semicolon.** Items were emitted as a multi-line
  `AddComponent(\n … ,\n)` — a trailing comma in the argument list and no terminating
  `;`, i.e. `CS1525: Invalid expression term ')'` and `CS1002: ; expected`.
- **`Deed` missing `new`.** The template emitted
  `public override BaseAddonDeed Deed => {name}AddonDeed();` — invoking the type as a
  method (`CS1955: Non-invocable member … cannot be used like a method`).

Both are now fixed; components are emitted on a single line matching the existing
static-tile path:

```csharp
AddComponent(new AddonComponent(3215) { Light = LightType.Circle300, Hue = 5 }, 2, 3, 5);
```

**Verification:** compiled the generator's *output* (a representative two-component addon —
one plain, one hued + light-source) before and after the change against minimal
`BaseAddon`/`AddonComponent` stubs:

- Before: `CS1525` + `CS1002` (item path), and `CS1955` in isolation for the `Deed` line.
- After: **Build succeeded.**

`Projects/UOContent` also builds clean with the source change.

## Gather-logic + UI (reasoned from the code, not runtime-tested)

- **Inverted Z-range guards.** The tile/item scan used `if (range && …)`, so with the range
  filter off (the default) map tiles and items were never captured — inconsistent with the
  Static pass's `if (!range || …)`. Corrected to match.
- **"Export Items" was dead unless "Export Statics" was also checked** — the items scan was
  nested inside `if (statics)`. Items now scan independently. Placed `Static` items are
  skipped in this path because they're already captured (with hue/light) by the
  `GetItemsInBounds<Static>` pass, which also removes a pre-existing double-count.
- **Swapped gump Min/Max labels** — the "Max" label sat over the Min entry and vice versa.

## Notes

The three gather/UI fixes are reasoned from the code rather than exercised through the
in-game gump, so they're worth a close look in review. The compile fixes are the headline
and are output-verified.
2026-07-12 10:15:25 -07:00

460 lines
14 KiB
C#

using System;
using System.Buffers;
using System.IO;
using System.Collections.Generic;
using Server.Collections;
using Server.Items;
using Server.Gumps;
using Server.Text;
namespace Server.Commands;
public class AddonGenerator
{
private static readonly string _outputDirectory = Path.Combine(Core.BaseDirectory, "Data", "Generated Addons");
public delegate void PickerCallbackDelegate(Mobile from, Map map, Point3D start, Point3D end, object state);
public static void PickerCallbackWrapper(Mobile from, Map map, Point3D start, Point3D end, PickerCallbackDelegate callback, object state)
{
callback(from, map, start, end, state);
}
private const string _template =
"""
/////////////////////////////////////////////////
//
// Automatically generated by the
// AddonGenerator script for ModernUO
//
/////////////////////////////////////////////////
using System;
using Server;{serverItemsNamespace}
namespace {namespace};
[SerializationGenerator(0)]
public class {name}Addon : BaseAddon
{
public override BaseAddonDeed Deed => new {name}AddonDeed();
[Constructible]
public {name}Addon()
{
{components}
}
}
[SerializationGenerator(0)]
public class {name}AddonDeed : BaseAddonDeed
{
public override BaseAddon Addon => new {name}Addon();
public override string DefaultName => "{name}";
[Constructible]
public {name}AddonDeed()
{
}
}
""";
private static readonly SearchValues<string> _templatePlaceholders = SearchValues.Create(["{serverItemsNamespace}", "{namespace}", "{name}", "{components}"], StringComparison.Ordinal);
private static string ExpandTemplate(
ReadOnlySpan<char> serverItemsNamespace,
ReadOnlySpan<char> ns,
ReadOnlySpan<char> name,
ReadOnlySpan<char> components
)
{
using var output = ValueStringBuilder.Create();
var remaining = _template.AsSpan();
int index;
while ((index = remaining.IndexOfAny(_templatePlaceholders)) >= 0)
{
output.Append(remaining[..index]);
var placeholder = remaining[index..];
if (placeholder.StartsWith("{serverItemsNamespace}"))
{
output.Append(serverItemsNamespace);
remaining = placeholder["{serverItemsNamespace}".Length..];
}
else if (placeholder.StartsWith("{namespace}"))
{
output.Append(ns);
remaining = placeholder["{namespace}".Length..];
}
else if (placeholder.StartsWith("{name}"))
{
output.Append(name);
remaining = placeholder["{name}".Length..];
}
else // {components}
{
output.Append(components);
remaining = placeholder["{components}".Length..];
}
}
output.Append(remaining);
return output.ToString();
}
public static void Configure()
{
CommandSystem.Register("AddonGen", AccessLevel.Administrator, OnAddonGen);
}
[Usage("AddonGen [<name> [namespace]]")]
[Description("Brings up the addon script generator gump. Specify a name to skip the gump and go directly to picking an area.")]
private static void OnAddonGen(CommandEventArgs e)
{
var pickerState = new PickerState
{
Name = "",
Namespace = "Server.Items",
Items = true,
Statics = true,
Range = false,
Min = sbyte.MinValue,
Max = sbyte.MaxValue
};
if (e.Arguments.Length <= 0)
{
// Send gump
e.Mobile.SendGump(new InternalGump(e.Mobile, pickerState));
return;
}
pickerState.Name = e.Arguments[0];
pickerState.Namespace = e.Arguments.Length > 1 ? e.Arguments[1] : "Server.Items";
BoundingBoxPicker.Begin(
e.Mobile,
(map, start, end) => PickerCallbackWrapper(e.Mobile, map, start, end, PickerCallback, pickerState)
);
}
private static void PickerCallback(Mobile from, Map map, Point3D start, Point3D end, object state)
{
if (state is not PickerState pickerState)
{
return;
}
if (start.X > end.X)
{
(start.X, end.X) = (end.X, start.X);
}
if (start.Y > end.Y)
{
(start.Y, end.Y) = (end.Y, start.Y);
}
var startPoint2D = new Point2D(start.X, start.Y);
var endPoint2D = new Point2D(end.X, end.Y);
var bounds = new Rectangle2D(startPoint2D, endPoint2D);
var name = pickerState.Name;
var ns = pickerState.Namespace;
var items = pickerState.Items;
var statics = pickerState.Statics;
var range = pickerState.Range;
var min = pickerState.Min;
var max = pickerState.Max;
// Get center
var center = new Point3D();
center.Z = 127;
var x1 = bounds.End.X;
var y1 = bounds.End.Y;
var x2 = bounds.Start.X;
var y2 = bounds.Start.Y;
var tiles = new Dictionary<Point2D, List<StaticTile>>();
if (statics || items)
{
for (var x = start.X; x <= end.X; x++)
{
for (var y = start.Y; y <= end.Y; y++)
{
var tileMatrix = map.Tiles;
List<StaticTile> list = [];
if (items)
{
foreach (var item in map.GetItemsAt(x, y))
{
// Placed Static items are captured (with hue/light) by the
// GetItemsInBounds<Static> pass below; skip them here to avoid duplicates.
if (item is Static)
{
continue;
}
if (!range || item.Z >= min && item.Z <= max)
{
list.Add(new StaticTile((ushort)item.ItemID, (sbyte)item.Z));
}
}
}
if (statics)
{
foreach (var staticTile in tileMatrix.GetStaticTiles(x, y))
{
if (!range || staticTile.Z >= min && staticTile.Z <= max)
{
list.Add(staticTile);
}
}
}
if (list is { Count: > 0 })
{
tiles[new Point2D(x, y)] = list;
}
}
}
}
using var target = PooledRefList<Item>.Create();
foreach (var s in map.GetItemsInBounds<Static>(bounds))
{
if (!range || s.Z >= min && s.Z <= max)
{
target.Add(s);
}
}
if (target.Count == 0 && tiles.Keys.Count == 0)
{
from.SendMessage(0x40, "No items have been selected");
return;
}
// Get correct bounds
foreach (var item in target)
{
if (item.Z < center.Z)
{
center.Z = item.Z;
}
x1 = Math.Min(x1, item.X);
y1 = Math.Min(y1, item.Y);
x2 = Math.Max(x2, item.X);
y2 = Math.Max(y2, item.Y);
}
foreach (var p in tiles.Keys)
{
var list = tiles[p];
foreach (var t in list)
{
if (t.Z < center.Z)
{
center.Z = t.Z;
}
}
x1 = Math.Min(x1, p.X);
y1 = Math.Min(y1, p.Y);
x2 = Math.Max(x2, p.X);
y2 = Math.Max(y2, p.Y);
}
center.X = x1 + (x2 - x1) / 2;
center.Y = y1 + (y2 - y1) / 2;
using var sb = ValueStringBuilder.Create();
foreach (var p in tiles.Keys)
{
var list = tiles[p];
var xOffset = p.X - center.X;
var yOffset = p.Y - center.Y;
foreach (var t in list)
{
var zOffset = t.Z - center.Z;
var id = t.ID - 16384;
sb.Append($" AddComponent(new AddonComponent({id}), {xOffset}, {yOffset}, {zOffset});\n");
}
}
foreach (var item in target)
{
var xOffset = item.X - center.X;
var yOffset = item.Y - center.Y;
var zOffset = item.Z - center.Z;
var light = (item.ItemData.Flags & TileFlag.LightSource) == TileFlag.LightSource ? item.Light : LightType.Empty;
var hue = item.Hue;
sb.Append($" AddComponent(new AddonComponent({item.ItemID})");
if (hue != 0 || light != LightType.Empty)
{
sb.Append(" {");
if (light != LightType.Empty)
{
sb.Append($" Light = LightType.{light}");
if (hue != 0)
{
sb.Append($", Hue = {hue}");
}
}
else
{
sb.Append($" Hue = {hue}");
}
sb.Append(" }");
}
sb.Append($", {xOffset}, {yOffset}, {zOffset});\n");
}
// Only pull in Server.Items when the generated addon lives in a different namespace.
var serverItemsNamespace = ns == "Server.Items" ? "" : "\nusing Server.Items;";
var output = ExpandTemplate(serverItemsNamespace, ns, name, sb.AsSpan());
var path = Path.Combine(_outputDirectory, $"{name}Addon.cs");
var folder = Path.GetDirectoryName(path);
PathUtility.EnsureDirectory(folder);
using var writer = new StreamWriter(path, false);
writer.Write(output);
from.SendMessage(0x40, $"Script saved to {path}");
}
private class PickerState
{
public string Name { get; set; }
public string Namespace { get; set; }
public bool Items { get; set; }
public bool Statics { get; set; }
public bool Range { get; set; }
public sbyte Min { get; set; }
public sbyte Max { get; set; }
}
private class InternalGump : Gump
{
private const int LabelHue = 0x480;
private const int GreenHue = 0x40;
private readonly PickerState _state;
public override bool Singleton => true;
public InternalGump(Mobile m, PickerState state) : base(100, 50)
{
_state = state;
MakeGump();
}
private void MakeGump()
{
Closable = true;
Disposable = true;
Draggable = true;
Resizable = false;
AddPage(0);
AddBackground(0, 0, 280, 225, 9270);
AddAlphaRegion(10, 10, 260, 205);
AddLabel(64, 15, GreenHue, "Addon Script Generator");
AddLabel(20, 40, LabelHue, "Name");
AddImageTiled(95, 55, 165, 1, 9304);
AddTextEntry(95, 35, 165, 20, LabelHue, 0, _state.Name);
AddLabel(20, 60, LabelHue, "Namespace");
AddImageTiled(95, 75, 165, 1, 9304);
AddTextEntry(95, 55, 165, 20, LabelHue, 1, _state.Namespace);
AddCheck(20, 85, 2510, 2511, _state.Items, 0);
AddLabel(40, 85, LabelHue, "Export Items");
AddCheck(20, 110, 2510, 2511, _state.Statics, 1);
AddLabel(40, 110, LabelHue, "Export Statics");
AddCheck(20, 135, 2510, 2511, _state.Range, 2);
AddLabel(40, 135, LabelHue, "Specify Z Range");
AddLabel(50, 160, LabelHue, "Min");
AddImageTiled(85, 175, 60, 1, 9304);
AddTextEntry(85, 155, 60, 20, LabelHue, 2, _state.Min.ToString());
AddLabel(160, 160, LabelHue, "Max");
AddImageTiled(200, 175, 60, 1, 9304);
AddTextEntry(200, 155, 60, 20, LabelHue, 3, _state.Max.ToString());
AddButton(20, 185, 4020, 4021, 0);
AddLabel(55, 185, LabelHue, "Cancel");
AddButton(155, 185, 4005, 4006, 1);
AddLabel(195, 185, LabelHue, "Generate");
}
public override void OnResponse(Network.NetState sender, in RelayInfo info)
{
if (info.ButtonID == 0)
{
return;
}
_state.Name = info.GetTextEntry(0);
_state.Namespace = info.GetTextEntry(1);
_state.Items = info.IsSwitched(0);
_state.Statics = info.IsSwitched(1);
_state.Range = info.IsSwitched(2);
var fail = !_state.Items && !_state.Statics;
if (!sbyte.TryParse(info.GetTextEntry(2), out var min))
{
min = sbyte.MinValue;
fail = true;
}
if (!sbyte.TryParse(info.GetTextEntry(3), out var max))
{
max = sbyte.MaxValue;
fail = true;
}
if (max < min)
{
(max, min) = (min, max);
}
_state.Min = min;
_state.Max = max;
if (fail)
{
sender.Mobile.SendMessage(0x40, "Please review the generation parameters, some are invalid.");
sender.Mobile.SendGump(new InternalGump(sender.Mobile, _state));
return;
}
BoundingBoxPicker.Begin(
sender.Mobile,
(map, start, end) => PickerCallbackWrapper(sender.Mobile, map, start, end, PickerCallback, _state)
);
}
}
}