feat(spawners): native compact writer for [ExportSpawners

Export now writes the compact spawn-file layout directly (homeRange/spawnBounds/location
inline, entries expanded, fits-in-100-cols rule), UTF-8 no BOM, LF — so re-exports stay
diff-friendly instead of the 4x WriteIndented blow-up. location serializes as the array
form [x, y, z] via a property-scoped converter (the default Point3DConverter writes an
object). Compact writer walks a JsonNode tree with a ValueStringBuilder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-06-25 18:45:04 -07:00
parent 3c9434a52a
commit dc8737bfbd
5 changed files with 277 additions and 1 deletions

View file

@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Text.Json;
using Server;
using Server.Engines.Spawners;
using Xunit;
namespace UOContent.Tests.Engines.Spawners.Json;
[Collection("Sequential UOContent Tests")]
public class SpawnerCompactWriterTests
{
[Fact]
public void SerializeCompact_ProducesCompactLoadableFormat()
{
Spawner original = null;
BaseSpawner rebuilt = null;
try
{
original = new Spawner("Fisherman");
original.MoveToWorld(new Point3D(200, 200, 0), Map.Felucca);
original.SpawnBounds = new Rectangle3D(195, 195, -128, 11, 11, 256); // == homeRange 5
var json = SpawnerJsonSerializer.SerializeCompact(new List<SpawnerDto> { original.ToDto() });
// No BOM (StartsWith '[' proves it), UTF-8, LF, trailing newline.
Assert.StartsWith("[", json);
Assert.DoesNotContain("\r", json);
Assert.EndsWith("]\n", json);
// $type first; scalar containers inline; entries (array of objects) expanded.
Assert.Contains("\"$type\": \"Spawner\"", json);
Assert.Contains("\"location\": [200, 200, 0]", json);
Assert.Contains("\"homeRange\": 5", json);
Assert.DoesNotContain("spawnBounds", json);
Assert.Contains("\"entries\": [\n", json); // array of objects -> expanded
Assert.Contains("{ \"name\": \"Fisherman\",", json); // each entry inline
// Round-trips back to an equivalent spawner.
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(json, SpawnerJsonSerializer.Options);
rebuilt = Assert.Single(dtos).ToSpawner();
Assert.Equal(new Rectangle3D(195, 195, -128, 11, 11, 256), rebuilt.SpawnBounds);
}
finally
{
rebuilt?.Delete();
original?.Delete();
}
}
}

View file

@ -94,7 +94,10 @@ public class ExportSpawnersCommand : BaseCommand
e.Mobile.SendMessage("Exporting spawners...");
JsonConfig.Serialize(path, spawnRecords, SpawnerJsonSerializer.Options);
// Compact layout (homeRange/spawnBounds inline, entries expanded), UTF-8, no BOM, LF —
// keeps re-exports diff-friendly. (JsonConfig.Serialize would pretty-print and 4x the size.)
PathUtility.EnsureDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, SpawnerJsonSerializer.SerializeCompact(spawnRecords));
e.Mobile.SendMessage($"Spawners exported to {path}");
}

View file

@ -0,0 +1,43 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: Point3DArrayConverter.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Server.Json;
namespace Server.Engines.Spawners;
/// <summary>
/// Writes a <see cref="Point3D"/> as the compact array form <c>[x, y, z]</c> used by the spawn
/// files (the default <see cref="Point3DConverter"/> writes an object). Reading delegates to the
/// default converter, so array/object/string inputs all still load.
/// </summary>
public sealed class Point3DArrayConverter : JsonConverter<Point3D>
{
private static readonly Point3DConverter _inner = new();
public override Point3D Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
_inner.Read(ref reader, typeToConvert, options);
public override void Write(Utf8JsonWriter writer, Point3D value, JsonSerializerOptions options)
{
writer.WriteStartArray();
writer.WriteNumberValue(value.X);
writer.WriteNumberValue(value.Y);
writer.WriteNumberValue(value.Z);
writer.WriteEndArray();
}
}

View file

@ -40,6 +40,7 @@ public abstract record SpawnerDto
public string Name { get; init; }
[JsonPropertyName("location")][JsonPropertyOrder(2)][JsonIgnore(Condition = JsonIgnoreCondition.Never)]
[JsonConverter(typeof(Point3DArrayConverter))]
public Point3D Location { get; init; }
[JsonPropertyName("map")][JsonPropertyOrder(3)][JsonIgnore(Condition = JsonIgnoreCondition.Never)]

View file

@ -15,11 +15,14 @@
using System;
using System.Collections.Generic;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Server.Json;
using Server.Logging;
using Server.Text;
namespace Server.Engines.Spawners;
@ -166,4 +169,181 @@ public static class SpawnerJsonSerializer
}
}
}
// --- Compact writer (matches the on-disk spawn-file layout used by [ExportSpawners) ---
private const int CompactPrintWidth = 100;
private static readonly JsonSerializerOptions _scalarOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Serializes <paramref name="value"/> (e.g. a <c>List&lt;SpawnerDto&gt;</c>) to the compact
/// spawn-file layout: a container renders inline only when all its values are scalars and the
/// line fits within 100 columns; otherwise it expands with 2-space indentation. UTF-8, LF, no
/// BOM. Admin/cold path — favors clarity over allocation.
/// </summary>
public static string SerializeCompact<T>(T value)
{
var node = JsonSerializer.SerializeToNode(value, Options);
var sb = ValueStringBuilder.Create();
try
{
WriteNode(node, ref sb, 0, 0);
sb.Append("\n");
return sb.ToString();
}
finally
{
sb.Dispose();
}
}
private static void WriteNode(JsonNode node, ref ValueStringBuilder sb, int indent, int column)
{
if (node is not JsonArray and not JsonObject)
{
sb.Append(node?.ToJsonString(_scalarOptions) ?? "null");
return;
}
if (AllScalarChildren(node))
{
var inline = Inline(node);
if (column + inline.Length <= CompactPrintWidth)
{
sb.Append(inline);
return;
}
}
var childIndent = indent + 1;
var childColumn = childIndent * 2;
if (node is JsonArray arr)
{
sb.Append("[\n");
for (var i = 0; i < arr.Count; i++)
{
sb.Append(' ', childColumn);
WriteNode(arr[i], ref sb, childIndent, childColumn);
sb.Append(i < arr.Count - 1 ? ",\n" : "\n");
}
sb.Append(' ', indent * 2);
sb.Append("]");
return;
}
var obj = (JsonObject)node;
sb.Append("{\n");
var index = 0;
var count = obj.Count;
foreach (var pair in obj)
{
sb.Append(' ', childColumn);
var prefix = $"\"{pair.Key}\": ";
sb.Append(prefix);
WriteNode(pair.Value, ref sb, childIndent, childColumn + prefix.Length);
sb.Append(++index < count ? ",\n" : "\n");
}
sb.Append(' ', indent * 2);
sb.Append("}");
}
private static bool AllScalarChildren(JsonNode node)
{
if (node is JsonArray arr)
{
foreach (var element in arr)
{
if (element is JsonArray or JsonObject)
{
return false;
}
}
return true;
}
foreach (var pair in (JsonObject)node)
{
if (pair.Value is JsonArray or JsonObject)
{
return false;
}
}
return true;
}
private static string Inline(JsonNode node)
{
var sb = ValueStringBuilder.Create();
try
{
AppendInline(node, ref sb);
return sb.ToString();
}
finally
{
sb.Dispose();
}
}
private static void AppendInline(JsonNode node, ref ValueStringBuilder sb)
{
switch (node)
{
case JsonArray arr:
{
sb.Append("[");
for (var i = 0; i < arr.Count; i++)
{
if (i > 0)
{
sb.Append(", ");
}
AppendInline(arr[i], ref sb);
}
sb.Append("]");
break;
}
case JsonObject obj:
{
if (obj.Count == 0)
{
sb.Append("{}");
break;
}
sb.Append("{ ");
var first = true;
foreach (var pair in obj)
{
if (!first)
{
sb.Append(", ");
}
first = false;
sb.Append("\"");
sb.Append(pair.Key);
sb.Append("\": ");
AppendInline(pair.Value, ref sb);
}
sb.Append(" }");
break;
}
default:
sb.Append(node?.ToJsonString(_scalarOptions) ?? "null");
break;
}
}
}