refactor(spawners): non-nullable SpawnerDto; write homeRange for square bounds

Drop nullable DTO types in favor of WhenWritingDefault + sentinels (ModernUO avoids
nullable). guid/location/map/count/minDelay/maxDelay/entries are mandatory (force-written
via [JsonIgnore(Never)]); optional fields omit at their default. spawnPositionMode/
maxSpawnAttempts map their non-CLR-default "omit" value onto the default in ToDto.

Reintroduce compact homeRange: ToDto writes homeRange (the radius) only when SpawnBounds is
EXACTLY what that radius reconstructs (square, centered, standard z/depth) so the round-trip
is lossless; otherwise it writes spawnBounds. homeRange uses a -1 "absent" sentinel and a
ShouldSerialize(hr >= 0) since 0 is a valid radius WhenWritingDefault cannot emit.
JsonPropertyOrder preserves the on-disk field order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-06-25 18:32:11 -07:00
parent 7c79dde63e
commit 3c9434a52a
7 changed files with 231 additions and 112 deletions

View file

@ -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<SpawnerDto> { 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<List<SpawnerDto>>(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<SpawnerDto> { original.ToDto() }, SpawnerJsonSerializer.Options);
Assert.Contains("\"homeRange\": 5", json);
Assert.DoesNotContain("spawnBounds", json);
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();
}
}
[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<SpawnerDto> { 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<SpawnerDto> { 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<List<SpawnerDto>>(json, SpawnerJsonSerializer.Options);
rebuilt = Assert.Single(dtos).ToSpawner();

View file

@ -22,45 +22,25 @@ public abstract partial class BaseSpawner
/// <summary>Applies the common DTO fields to this freshly-created spawner (import path).</summary>
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;
/// <summary>The square spawn bounds a homeRange radius represents (centered on the location).</summary>
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;
}
}
}

View file

@ -24,25 +24,51 @@ namespace Server.Engines.Spawners;
/// <summary>
/// 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. <see cref="ToSpawner"/> 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
/// <c>WhenWritingDefault</c> so optional fields are omitted at their default, mandatory fields
/// are force-written with <c>[JsonIgnore(Condition = Never)]</c>, and fields whose "omit" value
/// is not the CLR default (maxSpawnAttempts, spawnPositionMode) are mapped to the default in
/// the producing ToDto. <c>[JsonPropertyOrder]</c> preserves the on-disk field order.
/// </summary>
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<SpawnerEntry> 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<SpawnerEntry> 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;
/// <summary>Constructs the empty concrete spawner Item for this DTO.</summary>
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;

View file

@ -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
};
}
}

View file

@ -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
};
}

View file

@ -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
};
}
}

View file

@ -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;
}
}
}
}