diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerCompactWriterTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerCompactWriterTests.cs new file mode 100644 index 000000000..d25abd9a0 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerCompactWriterTests.cs @@ -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 { 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>(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(); + } + } +} diff --git a/Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs index 24e0eb2ad..f3ebeb073 100644 --- a/Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs +++ b/Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs @@ -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}"); } diff --git a/Projects/UOContent/Engines/Spawners/Json/Point3DArrayConverter.cs b/Projects/UOContent/Engines/Spawners/Json/Point3DArrayConverter.cs new file mode 100644 index 000000000..0b6edb738 --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/Json/Point3DArrayConverter.cs @@ -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 . * + *************************************************************************/ + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Engines.Spawners; + +/// +/// Writes a as the compact array form [x, y, z] used by the spawn +/// files (the default writes an object). Reading delegates to the +/// default converter, so array/object/string inputs all still load. +/// +public sealed class Point3DArrayConverter : JsonConverter +{ + 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(); + } +} diff --git a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs index bb06471e3..1e9e6c198 100644 --- a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs +++ b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs @@ -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)] diff --git a/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs b/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs index a20478d5a..0e264e7bd 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs @@ -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 + }; + + /// + /// Serializes (e.g. a List<SpawnerDto>) 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. + /// + public static string SerializeCompact(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; + } + } }