diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs index 25785d658..13ba62da3 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs @@ -12,12 +12,13 @@ namespace UOContent.Tests.Engines.Spawners.Json; public class SpawnerDtoRoundTripTests { [Fact] - public void Spawner_RoundTrips_ThroughDto() + public void Spawner_NonSquareBounds_WritesSpawnBounds_RoundTrips() { Spawner original = null; BaseSpawner rebuilt = null; try { + // Bounds centered on (102,102), placed at (105,105) -> not a homeRange square. original = new Spawner(2, TimeSpan.FromMinutes(3), TimeSpan.FromMinutes(7), 0, new Rectangle3D(100, 100, 0, 5, 5, 0), "Fisherman"); original.MoveToWorld(new Point3D(105, 105, 0), Map.Felucca); @@ -25,6 +26,8 @@ public class SpawnerDtoRoundTripTests var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); Assert.Contains("\"$type\": \"Spawner\"", json); Assert.Contains("\"count\": 2", json); + Assert.Contains("spawnBounds", json); + Assert.DoesNotContain("homeRange", json); var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); rebuilt = Assert.Single(dtos).ToSpawner(); @@ -42,18 +45,52 @@ public class SpawnerDtoRoundTripTests } [Fact] - public void Spawner_OmitsDomainDefaults() + public void Spawner_HomeRangeSquare_WritesHomeRange_RoundTrips() + { + Spawner original = null; + BaseSpawner rebuilt = null; + try + { + original = new Spawner("Fisherman"); + original.MoveToWorld(new Point3D(200, 200, 0), Map.Felucca); + // Exactly what homeRange 5 reconstructs (centered square, standard z/depth). + original.SpawnBounds = new Rectangle3D(195, 195, -128, 11, 11, 256); + + var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); + Assert.Contains("\"homeRange\": 5", json); + Assert.DoesNotContain("spawnBounds", json); + + 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(); + } + } + + [Fact] + public void Spawner_WritesMandatory_OmitsOptionalDefaults() { Spawner original = null; try { + // Default delays (5/10), team 0, default maxSpawnAttempts. original = new Spawner("Fisherman"); original.MoveToWorld(new Point3D(110, 110, 0), Map.Felucca); var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); - Assert.DoesNotContain("minDelay", json); - Assert.DoesNotContain("maxDelay", json); + + // Mandatory — always written, even at default. + Assert.Contains("\"guid\":", json); + Assert.Contains("minDelay", json); + Assert.Contains("maxDelay", json); + + // Optional defaults — omitted. Assert.DoesNotContain("\"team\"", json); Assert.DoesNotContain("maxSpawnAttempts", json); + Assert.DoesNotContain("spawnLocationIsHome", json); } finally { @@ -75,6 +112,8 @@ public class SpawnerDtoRoundTripTests var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); Assert.Contains("\"$type\": \"RegionSpawner\"", json); Assert.Contains("DtoTestRegion", json); + Assert.DoesNotContain("homeRange", json); + Assert.DoesNotContain("spawnBounds", json); var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); rebuilt = Assert.Single(dtos).ToSpawner(); diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs index 98bc81b40..af5fae281 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs @@ -22,45 +22,25 @@ public abstract partial class BaseSpawner /// Applies the common DTO fields to this freshly-created spawner (import path). internal void ApplyDto(SpawnerDto dto) { - _guid = dto.Guid ?? Guid.NewGuid(); + _guid = dto.Guid == Guid.Empty ? Guid.NewGuid() : dto.Guid; if (!string.IsNullOrEmpty(dto.Name)) { Name = dto.Name; } - // Legacy homeRange -> spawnBounds (Map not available yet; use the DTO location). - if (dto.HomeRange is int homeRange && homeRange >= 0) + // Compact homeRange -> spawnBounds (Map not available yet; use the DTO location). + if (dto.HomeRange >= 0) { - int z; - int depth; - if (homeRange == 0) - { - z = dto.Location.Z; - depth = 0; - } - else - { - z = -128; - depth = 256; - } - - SpawnBounds = new Rectangle3D( - dto.Location.X - homeRange, - dto.Location.Y - homeRange, - z, - homeRange * 2 + 1, - homeRange * 2 + 1, - depth - ); + SpawnBounds = BoundsFromHomeRange(dto.Location, dto.HomeRange); } - InitSpawn(dto.Count, dto.MinDelay ?? DefaultMinDelay, dto.MaxDelay ?? DefaultMaxDelay, dto.Team ?? 0, SpawnBounds); + InitSpawn(dto.Count, dto.MinDelay, dto.MaxDelay, dto.Team, SpawnBounds); - _walkingRange = dto.WalkingRange ?? -1; - _spawnLocationIsHome = dto.SpawnLocationIsHome ?? false; - _spawnPositionMode = dto.SpawnPositionMode ?? SpawnPositionMode.Automatic; - _maxSpawnAttempts = dto.MaxSpawnAttempts ?? DefaultMaxSpawnAttempts; + _walkingRange = dto.WalkingRange; + _spawnLocationIsHome = dto.SpawnLocationIsHome; + _spawnPositionMode = dto.SpawnPositionMode; + _maxSpawnAttempts = dto.MaxSpawnAttempts; if (dto.Entries != null) { @@ -72,22 +52,63 @@ public abstract partial class BaseSpawner } } - // Export helpers — nullable so WhenWritingNull omits domain defaults, matching legacy ToJson. - private protected Guid? DtoGuid => _guid; + /// The square spawn bounds a homeRange radius represents (centered on the location). + private protected static Rectangle3D BoundsFromHomeRange(Point3D location, int homeRange) + { + int z; + int depth; + if (homeRange == 0) + { + z = location.Z; + depth = 0; + } + else + { + z = -128; + depth = 256; + } + + return new Rectangle3D( + location.X - homeRange, + location.Y - homeRange, + z, + homeRange * 2 + 1, + homeRange * 2 + 1, + depth + ); + } + + // Export helpers. Options use WhenWritingDefault, so optional fields are omitted at their CLR + // default; helpers below map non-CLR-default "omit" values onto the default so they drop out. + private protected Guid DtoGuid => _guid; private protected string DtoName => string.IsNullOrEmpty(Name) ? null : Name; - private protected TimeSpan? DtoMinDelay => _minDelay == DefaultMinDelay ? null : _minDelay; - private protected TimeSpan? DtoMaxDelay => _maxDelay == DefaultMaxDelay ? null : _maxDelay; - private protected int? DtoTeam => _team == 0 ? null : _team; - private protected int? DtoWalkingRange => _walkingRange != 0 ? WalkingRange : null; - private protected bool? DtoSpawnLocationIsHome => _spawnLocationIsHome ? true : null; + private protected TimeSpan DtoMinDelay => _minDelay; + private protected TimeSpan DtoMaxDelay => _maxDelay; + private protected int DtoTeam => _team; + private protected int DtoWalkingRange => _walkingRange; + private protected bool DtoSpawnLocationIsHome => _spawnLocationIsHome; - private protected SpawnPositionMode? DtoSpawnPositionMode => - _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned - ? _spawnPositionMode - : null; + // Abandoned is a transient "gave up" runtime state, not persisted -> map to Automatic (omitted). + private protected SpawnPositionMode DtoSpawnPositionMode => + _spawnPositionMode == SpawnPositionMode.Abandoned ? SpawnPositionMode.Automatic : _spawnPositionMode; - // Runtime treats 0 identically to DefaultMaxSpawnAttempts (BaseSpawner.cs maxAttempts clamp), - // so both are omitted — fresh spawners have _maxSpawnAttempts == 0. - private protected int? DtoMaxSpawnAttempts => - _maxSpawnAttempts > 0 && _maxSpawnAttempts != DefaultMaxSpawnAttempts ? _maxSpawnAttempts : null; + // Runtime treats 0 identically to DefaultMaxSpawnAttempts(10) -> map the default to 0 (omitted). + private protected int DtoMaxSpawnAttempts => + _maxSpawnAttempts == DefaultMaxSpawnAttempts ? 0 : _maxSpawnAttempts; + + // The homeRange radius if SpawnBounds is EXACTLY what that radius reconstructs (square, centered, + // standard z/depth) so the round-trip is lossless; otherwise -1 (write spawnBounds instead). + private protected int DtoHomeRange + { + get + { + if (SpawnBounds == default || !IsHomeRangeStyle) + { + return -1; + } + + var homeRange = HomeRange; + return SpawnBounds == BoundsFromHomeRange(Location, homeRange) ? homeRange : -1; + } + } } diff --git a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs index 725d7c236..bb06471e3 100644 --- a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs +++ b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs @@ -24,25 +24,51 @@ namespace Server.Engines.Spawners; /// /// Plain data carrier for spawner JSON. System.Text.Json deserializes into these records /// (never a live Item), so a malformed file fails as GC only. builds -/// the real spawner from a fully-validated DTO. Sparse fields are nullable so WhenWritingNull -/// omits domain defaults, matching the legacy ToJson output. +/// the real spawner from a fully-validated DTO. No nullable types: the serializer options use +/// WhenWritingDefault so optional fields are omitted at their default, mandatory fields +/// are force-written with [JsonIgnore(Condition = Never)], and fields whose "omit" value +/// is not the CLR default (maxSpawnAttempts, spawnPositionMode) are mapped to the default in +/// the producing ToDto. [JsonPropertyOrder] preserves the on-disk field order. /// public abstract record SpawnerDto { - [JsonPropertyName("guid")] public Guid? Guid { get; init; } - [JsonPropertyName("location")] public Point3D Location { get; init; } - [JsonPropertyName("map")] public Map Map { get; init; } - [JsonPropertyName("count")] public int Count { get; init; } - [JsonPropertyName("name")] public string Name { get; init; } - [JsonPropertyName("minDelay")] public TimeSpan? MinDelay { get; init; } - [JsonPropertyName("maxDelay")] public TimeSpan? MaxDelay { get; init; } - [JsonPropertyName("team")] public int? Team { get; init; } - [JsonPropertyName("walkingRange")] public int? WalkingRange { get; init; } - [JsonPropertyName("homeRange")] public int? HomeRange { get; init; } // legacy read; never written - [JsonPropertyName("spawnLocationIsHome")] public bool? SpawnLocationIsHome { get; init; } - [JsonPropertyName("spawnPositionMode")] public SpawnPositionMode? SpawnPositionMode { get; init; } - [JsonPropertyName("maxSpawnAttempts")] public int? MaxSpawnAttempts { get; init; } - [JsonPropertyName("entries")] public List Entries { get; init; } + // --- Mandatory: always written (overrides the options' WhenWritingDefault) --- + [JsonPropertyName("guid")][JsonPropertyOrder(0)][JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public Guid Guid { get; init; } + + [JsonPropertyName("name")][JsonPropertyOrder(1)] + public string Name { get; init; } + + [JsonPropertyName("location")][JsonPropertyOrder(2)][JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public Point3D Location { get; init; } + + [JsonPropertyName("map")][JsonPropertyOrder(3)][JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public Map Map { get; init; } + + [JsonPropertyName("count")][JsonPropertyOrder(4)][JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public int Count { get; init; } + + [JsonPropertyName("minDelay")][JsonPropertyOrder(5)][JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public TimeSpan MinDelay { get; init; } + + [JsonPropertyName("maxDelay")][JsonPropertyOrder(6)][JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public TimeSpan MaxDelay { get; init; } + + // --- Optional: omitted at default via WhenWritingDefault (spawnBounds/region are on subtypes, order 8) --- + [JsonPropertyName("team")][JsonPropertyOrder(7)] public int Team { get; init; } + [JsonPropertyName("walkingRange")][JsonPropertyOrder(9)] public int WalkingRange { get; init; } + + [JsonPropertyName("entries")][JsonPropertyOrder(10)][JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List Entries { get; init; } + + [JsonPropertyName("spawnLocationIsHome")][JsonPropertyOrder(11)] public bool SpawnLocationIsHome { get; init; } + [JsonPropertyName("spawnPositionMode")][JsonPropertyOrder(12)] public SpawnPositionMode SpawnPositionMode { get; init; } + [JsonPropertyName("maxSpawnAttempts")][JsonPropertyOrder(13)] public int MaxSpawnAttempts { get; init; } + + // Compact square-bounds form. -1 = "not a homeRange square" (use spawnBounds instead). Written + // only when >= 0 (SpawnerJsonSerializer sets ShouldSerialize: hr >= 0, since 0 is a valid radius + // that WhenWritingDefault cannot emit). On read it reconstructs SpawnBounds in ApplyDto. + [JsonPropertyName("homeRange")][JsonPropertyOrder(8)] public int HomeRange { get; init; } = -1; /// Constructs the empty concrete spawner Item for this DTO. protected abstract BaseSpawner CreateEmpty(); @@ -69,7 +95,7 @@ public abstract record SpawnerDto [JsonDiscoverableType("Spawner")] public sealed record SpawnerDataDto : SpawnerDto { - [JsonPropertyName("spawnBounds")] public Rectangle3D? SpawnBounds { get; init; } + [JsonPropertyName("spawnBounds")][JsonPropertyOrder(8)] public Rectangle3D SpawnBounds { get; init; } protected override BaseSpawner CreateEmpty() => new Spawner(); @@ -78,9 +104,9 @@ public sealed record SpawnerDataDto : SpawnerDto var spawner = (Spawner)base.ToSpawner(); try { - if (SpawnBounds is { } bounds && bounds != default) + if (SpawnBounds != default) { - spawner.SpawnBounds = bounds; + spawner.SpawnBounds = SpawnBounds; } return spawner; @@ -96,7 +122,7 @@ public sealed record SpawnerDataDto : SpawnerDto [JsonDiscoverableType("RegionSpawner")] public sealed record RegionSpawnerDto : SpawnerDto { - [JsonPropertyName("region")] public string Region { get; init; } + [JsonPropertyName("region")][JsonPropertyOrder(8)] public string Region { get; init; } protected override BaseSpawner CreateEmpty() => new RegionSpawner(); @@ -119,10 +145,10 @@ public sealed record RegionSpawnerDto : SpawnerDto [JsonDiscoverableType("ProximitySpawner")] public sealed record ProximitySpawnerDto : SpawnerDto { - [JsonPropertyName("spawnBounds")] public Rectangle3D? SpawnBounds { get; init; } - [JsonPropertyName("triggerRange")] public int TriggerRange { get; init; } - [JsonPropertyName("spawnMessage")] public TextDefinition SpawnMessage { get; init; } - [JsonPropertyName("instant")] public bool Instant { get; init; } + [JsonPropertyName("spawnBounds")][JsonPropertyOrder(8)] public Rectangle3D SpawnBounds { get; init; } + [JsonPropertyName("triggerRange")][JsonPropertyOrder(14)] public int TriggerRange { get; init; } + [JsonPropertyName("spawnMessage")][JsonPropertyOrder(15)] public TextDefinition SpawnMessage { get; init; } + [JsonPropertyName("instant")][JsonPropertyOrder(16)] public bool Instant { get; init; } protected override BaseSpawner CreateEmpty() => new ProximitySpawner(); @@ -131,9 +157,9 @@ public sealed record ProximitySpawnerDto : SpawnerDto var spawner = (ProximitySpawner)base.ToSpawner(); try { - if (SpawnBounds is { } bounds && bounds != default) + if (SpawnBounds != default) { - spawner.SpawnBounds = bounds; + spawner.SpawnBounds = SpawnBounds; } spawner.TriggerRange = TriggerRange; diff --git a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs index feb6ec1a7..a74b7a358 100644 --- a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs @@ -17,24 +17,29 @@ namespace Server.Engines.Spawners; public partial class ProximitySpawner { - public override SpawnerDto ToDto() => new ProximitySpawnerDto + public override SpawnerDto ToDto() { - Guid = DtoGuid, - Location = Location, - Map = Map, - Count = Count, - Name = DtoName, - MinDelay = DtoMinDelay, - MaxDelay = DtoMaxDelay, - Team = DtoTeam, - WalkingRange = DtoWalkingRange, - SpawnLocationIsHome = DtoSpawnLocationIsHome, - SpawnPositionMode = DtoSpawnPositionMode, - MaxSpawnAttempts = DtoMaxSpawnAttempts, - Entries = Entries, - SpawnBounds = SpawnBounds == default ? null : SpawnBounds, - TriggerRange = TriggerRange, - SpawnMessage = SpawnMessage, - Instant = InstantFlag - }; + var homeRange = DtoHomeRange; + return new ProximitySpawnerDto + { + Guid = DtoGuid, + Name = DtoName, + Location = Location, + Map = Map, + Count = Count, + MinDelay = DtoMinDelay, + MaxDelay = DtoMaxDelay, + Team = DtoTeam, + WalkingRange = DtoWalkingRange, + Entries = Entries, + SpawnLocationIsHome = DtoSpawnLocationIsHome, + SpawnPositionMode = DtoSpawnPositionMode, + MaxSpawnAttempts = DtoMaxSpawnAttempts, + HomeRange = homeRange, + SpawnBounds = homeRange >= 0 ? default : SpawnBounds, + TriggerRange = TriggerRange, + SpawnMessage = SpawnMessage, + Instant = InstantFlag + }; + } } diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs index 3f15e25f3..23e8ebcb4 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs @@ -17,21 +17,22 @@ namespace Server.Engines.Spawners; public partial class RegionSpawner { + // RegionSpawner spawns from region rectangles, so it writes neither homeRange nor spawnBounds. public override SpawnerDto ToDto() => new RegionSpawnerDto { Guid = DtoGuid, + Name = DtoName, Location = Location, Map = Map, Count = Count, - Name = DtoName, MinDelay = DtoMinDelay, MaxDelay = DtoMaxDelay, Team = DtoTeam, WalkingRange = DtoWalkingRange, + Entries = Entries, SpawnLocationIsHome = DtoSpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, - Entries = Entries, Region = SpawnRegion?.Name }; } diff --git a/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs index 2b329e7bb..6d867089f 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs @@ -17,21 +17,26 @@ namespace Server.Engines.Spawners; public partial class Spawner { - public override SpawnerDto ToDto() => new SpawnerDataDto + public override SpawnerDto ToDto() { - Guid = DtoGuid, - Location = Location, - Map = Map, - Count = Count, - Name = DtoName, - MinDelay = DtoMinDelay, - MaxDelay = DtoMaxDelay, - Team = DtoTeam, - WalkingRange = DtoWalkingRange, - SpawnLocationIsHome = DtoSpawnLocationIsHome, - SpawnPositionMode = DtoSpawnPositionMode, - MaxSpawnAttempts = DtoMaxSpawnAttempts, - Entries = Entries, - SpawnBounds = SpawnBounds == default ? null : SpawnBounds - }; + var homeRange = DtoHomeRange; + return new SpawnerDataDto + { + Guid = DtoGuid, + Name = DtoName, + Location = Location, + Map = Map, + Count = Count, + MinDelay = DtoMinDelay, + MaxDelay = DtoMaxDelay, + Team = DtoTeam, + WalkingRange = DtoWalkingRange, + Entries = Entries, + SpawnLocationIsHome = DtoSpawnLocationIsHome, + SpawnPositionMode = DtoSpawnPositionMode, + MaxSpawnAttempts = DtoMaxSpawnAttempts, + HomeRange = homeRange, + SpawnBounds = homeRange >= 0 ? default : SpawnBounds + }; + } } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs b/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs index 34d057f66..a20478d5a 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs @@ -122,11 +122,15 @@ public static class SpawnerJsonSerializer public static JsonSerializerOptions Options => _options ??= new JsonSerializerOptions(JsonConfig.GetOptions(new TextDefinitionConverterFactory())) { + // Optional DTO fields are omitted at their CLR default; mandatory fields force-write with + // [JsonIgnore(Condition = Never)]. (homeRange is special-cased below since 0 is valid.) + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, TypeInfoResolver = new DefaultJsonTypeInfoResolver { Modifiers = { - AddPolymorphism + AddPolymorphism, + ConfigureHomeRange } } }; @@ -144,4 +148,22 @@ public static class SpawnerJsonSerializer typeInfo.PolymorphismOptions.DerivedTypes.Add(_derivedTypes[i]); } } + + // homeRange uses -1 as the "absent" sentinel (a real radius is >= 0, and 0 is a valid radius that + // WhenWritingDefault could not emit). Write it only when it represents a real homeRange square. + private static void ConfigureHomeRange(JsonTypeInfo typeInfo) + { + if (!typeInfo.Type.IsAssignableTo(typeof(SpawnerDto))) + { + return; + } + + for (var i = 0; i < typeInfo.Properties.Count; i++) + { + if (typeInfo.Properties[i].Name == "homeRange") + { + typeInfo.Properties[i].ShouldSerialize = static (_, value) => value is int hr && hr >= 0; + } + } + } }