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:
parent
3c9434a52a
commit
dc8737bfbd
5 changed files with 277 additions and 1 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<SpawnerDto></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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue