refactor(spawners): Approach A→B swap — deserialize via SpawnerDto records (atomic)

Replace the shadow-property (Approach A) JSON mechanism with plain DTO records
so malformed files fail as GC-only and never leak live world Items.

Changes:
- Add Json/SpawnerDto.cs: abstract SpawnerDto + SpawnerDataDto / RegionSpawnerDto /
  ProximitySpawnerDto, each [JsonDiscoverableType("<name>")] with ToSpawner()
- Add BaseSpawner.Dto.cs: internal ApplyDto(SpawnerDto) + private-protected Dto*
  export helpers (DtoGuid, DtoMinDelay, etc.)
- Add Spawner.Dto.cs, RegionSpawner.Dto.cs, ProximitySpawner.Dto.cs: ToDto() overrides
- Add public abstract SpawnerDto ToDto() to BaseSpawner
- Retarget SpawnerJsonSerializer: discovery filter BaseSpawner→SpawnerDto; polymorphism
  gate typeof(SpawnerDto); remove PruneToJsonProperties + AddOnDeserialized modifiers
- Delete BaseSpawner.Json.cs, Spawner.Json.cs, RegionSpawner.Json.cs,
  ProximitySpawner.Json.cs (Approach A shadow-property partials)
- Remove [JsonDiscoverableType] + [JsonConstructor] from Spawner/RegionSpawner/ProximitySpawner
- Rewire ExportSpawnersCommand: build List<SpawnerDto> via spawner.ToDto()
- Rewire ImportSpawnersCommand: Deserialize<List<SpawnerDto>> then dto.ToSpawner();
  map-null check now fires before ToSpawner(), so no orphan Items on bad map
- Delete 3 Approach-A round-trip tests; add SpawnerDtoRoundTripTests (4 cases)
- Update ExportImportFileTests + LegacyHomeRangeTests to use DTO path
- Add Import_MalformedFile_LeaksNoWorldItems to ImportCleanupTests

Build: dotnet build ModernUO.slnx → 0 errors, 0 warnings
Tests: dotnet test UOContent.Tests → 478/478 pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-06-25 15:46:47 -07:00
parent a9d61ccd35
commit bf31bceea4
20 changed files with 486 additions and 534 deletions

View file

@ -14,27 +14,29 @@ public class ExportImportFileTests
[Fact]
public void Serialize_ThenDeserialize_File_PreservesSpawner()
{
var spawner = new Spawner(3, TimeSpan.FromMinutes(4), TimeSpan.FromMinutes(8), 1,
new Rectangle3D(200, 200, 0, 9, 9, 0), "Tanner");
spawner.MoveToWorld(new Point3D(204, 204, 0), Map.Felucca);
Spawner original = null;
BaseSpawner rebuilt = null;
var path = Path.GetTempFileName();
try
{
JsonConfig.Serialize(path, new List<BaseSpawner> { spawner }, SpawnerJsonSerializer.Options);
original = new Spawner(3, TimeSpan.FromMinutes(4), TimeSpan.FromMinutes(8), 1,
new Rectangle3D(200, 200, 0, 9, 9, 0), "Tanner");
original.MoveToWorld(new Point3D(204, 204, 0), Map.Felucca);
var loaded = JsonConfig.Deserialize<List<BaseSpawner>>(path, SpawnerJsonSerializer.Options);
var s = Assert.IsType<Spawner>(Assert.Single(loaded));
JsonConfig.Serialize(path, new List<SpawnerDto> { original.ToDto() }, SpawnerJsonSerializer.Options);
var dtos = JsonConfig.Deserialize<List<SpawnerDto>>(path, SpawnerJsonSerializer.Options);
rebuilt = Assert.Single(dtos).ToSpawner();
var s = Assert.IsType<Spawner>(rebuilt);
Assert.Equal(3, s.Count);
Assert.Equal(1, s.Team);
Assert.Equal(new Rectangle3D(200, 200, 0, 9, 9, 0), s.SpawnBounds);
s.Delete();
}
finally
{
rebuilt?.Delete();
original?.Delete();
File.Delete(path);
spawner?.Delete();
}
}
}

View file

@ -98,6 +98,43 @@ public class ImportCleanupTests
}
}
[Fact]
public void Import_MalformedFile_LeaksNoWorldItems()
{
// First entry is syntactically valid JSON for a Spawner, second entry is truncated/garbage.
// STJ throws a JsonException during array deserialization before any Items are created
// (the DTO path is GC-only), so zero new spawners should appear at the valid entry's location.
var dir = Path.Combine(Path.GetTempPath(), "muo-import-malformed-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "bad.json"), """
[ { "$type": "Spawner", "location": [320, 320, 0], "map": "Felucca", "count": 1,
"entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ] },
{ "$type": "Spawner", "location": [ THIS IS NOT JSON
""");
try
{
var before = CountSpawnersNear(new Point3D(320, 320, 0));
ImportSpawnersCommand.ImportFile(new FileInfo(Path.Combine(dir, "bad.json")), new Dictionary<Guid, ISpawner>());
// Malformed parse must construct zero Items (DTO is GC-only); nothing placed or orphaned.
Assert.Equal(before, CountSpawnersNear(new Point3D(320, 320, 0)));
}
finally
{
Directory.Delete(dir, true);
}
}
private static int CountSpawnersNear(Point3D p)
{
var n = 0;
foreach (var _ in Map.Felucca.GetItemsAt<BaseSpawner>(p))
{
n++;
}
return n;
}
[Fact]
public void Import_DuplicateLocation_ReplacesExistingSpawner()
{

View file

@ -26,13 +26,19 @@ public class LegacyHomeRangeTests
]
""";
var rt = JsonSerializer.Deserialize<List<BaseSpawner>>(legacy, SpawnerJsonSerializer.Options);
var s = Assert.IsType<Spawner>(Assert.Single(rt));
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(legacy, SpawnerJsonSerializer.Options);
var dto = Assert.Single(dtos);
var s = Assert.IsType<Spawner>(dto.ToSpawner());
// homeRange 3 -> Rectangle3D(100-3, 200-3, -128, 7, 7, 256)
Assert.Equal(new Rectangle3D(97, 197, -128, 7, 7, 256), s.SpawnBounds);
s.Delete();
try
{
// homeRange 3 -> Rectangle3D(100-3, 200-3, -128, 7, 7, 256)
Assert.Equal(new Rectangle3D(97, 197, -128, 7, 7, 256), s.SpawnBounds);
}
finally
{
s.Delete();
}
}
[Fact]
@ -51,12 +57,18 @@ public class LegacyHomeRangeTests
]
""";
var rt = JsonSerializer.Deserialize<List<BaseSpawner>>(legacy, SpawnerJsonSerializer.Options);
var s = Assert.IsType<Spawner>(Assert.Single(rt));
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(legacy, SpawnerJsonSerializer.Options);
var dto = Assert.Single(dtos);
var s = Assert.IsType<Spawner>(dto.ToSpawner());
// homeRange 0 -> Rectangle3D(100, 200, 5, 1, 1, 0)
Assert.Equal(new Rectangle3D(100, 200, 5, 1, 1, 0), s.SpawnBounds);
s.Delete();
try
{
// homeRange 0 -> Rectangle3D(100, 200, 5, 1, 1, 0)
Assert.Equal(new Rectangle3D(100, 200, 5, 1, 1, 0), s.SpawnBounds);
}
finally
{
s.Delete();
}
}
}

View file

@ -1,41 +0,0 @@
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 ProximitySpawnerRoundTripTests
{
[Fact]
public void ProximitySpawner_RoundTrips_ProximityFields()
{
ProximitySpawner spawner = null;
ProximitySpawner s = null;
try
{
spawner = new ProximitySpawner("Fisherman") { TriggerRange = 4, InstantFlag = true };
spawner.SpawnMessage = 500000;
spawner.MoveToWorld(new Point3D(120, 120, 0), Map.Felucca);
var json = JsonSerializer.Serialize<List<BaseSpawner>>(
new List<BaseSpawner> { spawner }, SpawnerJsonSerializer.Options);
Assert.Contains("\"$type\": \"ProximitySpawner\"", json);
Assert.Contains("\"triggerRange\": 4", json);
Assert.Contains("\"instant\": true", json);
var rt = JsonSerializer.Deserialize<List<BaseSpawner>>(json, SpawnerJsonSerializer.Options);
s = Assert.IsType<ProximitySpawner>(Assert.Single(rt));
Assert.Equal(4, s.TriggerRange);
Assert.True(s.InstantFlag);
Assert.Equal(500000, s.SpawnMessage.Number);
}
finally
{
s?.Delete();
spawner?.Delete();
}
}
}

View file

@ -1,49 +0,0 @@
using System.Collections.Generic;
using System.Text.Json;
using Server;
using Server.Engines.Spawners;
using Server.Regions;
using Xunit;
namespace UOContent.Tests.Engines.Spawners.Json;
[Collection("Sequential UOContent Tests")]
public class RegionSpawnerRoundTripTests
{
[Fact]
public void RegionSpawner_RoundTrips_RegionByName()
{
// The test environment does not load game regions (no AssemblyHandler.Invoke("Initialize")),
// so we create and register a test BaseRegion on Felucca directly.
var region = new BaseRegion(
"TestSpawnRegion",
Map.Felucca,
50,
new Rectangle3D(1400, 1670, -128, 40, 40, 256)
);
region.Register();
RegionSpawner spawner = null;
RegionSpawner s = null;
try
{
spawner = new RegionSpawner("Fisherman") { SpawnRegion = region };
spawner.MoveToWorld(new Point3D(1416, 1683, 0), Map.Felucca);
var json = JsonSerializer.Serialize<List<BaseSpawner>>(
new List<BaseSpawner> { spawner }, SpawnerJsonSerializer.Options);
Assert.Contains("\"$type\": \"RegionSpawner\"", json);
Assert.Contains(region.Name, json);
var rt = JsonSerializer.Deserialize<List<BaseSpawner>>(json, SpawnerJsonSerializer.Options);
s = Assert.IsType<RegionSpawner>(Assert.Single(rt));
Assert.Equal(region.Name, s.SpawnRegion?.Name);
}
finally
{
s?.Delete();
spawner?.Delete();
region.Unregister();
}
}
}

View file

@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Server;
using Server.Engines.Spawners;
using Server.Regions;
using Xunit;
namespace UOContent.Tests.Engines.Spawners.Json;
[Collection("Sequential UOContent Tests")]
public class SpawnerDtoRoundTripTests
{
[Fact]
public void Spawner_RoundTrips_ThroughDto()
{
Spawner original = null;
BaseSpawner rebuilt = null;
try
{
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);
var json = JsonSerializer.Serialize(new List<SpawnerDto> { original.ToDto() }, SpawnerJsonSerializer.Options);
Assert.Contains("\"$type\": \"Spawner\"", json);
Assert.Contains("\"count\": 2", json);
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(json, SpawnerJsonSerializer.Options);
rebuilt = Assert.Single(dtos).ToSpawner();
var s = Assert.IsType<Spawner>(rebuilt);
Assert.Equal(2, s.Count);
Assert.Equal(TimeSpan.FromMinutes(3), s.MinDelay);
Assert.Equal(new Rectangle3D(100, 100, 0, 5, 5, 0), s.SpawnBounds);
Assert.Equal("Fisherman", Assert.Single(s.Entries).SpawnedName);
}
finally
{
rebuilt?.Delete();
original?.Delete();
}
}
[Fact]
public void Spawner_OmitsDomainDefaults()
{
Spawner original = null;
try
{
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);
Assert.DoesNotContain("\"team\"", json);
Assert.DoesNotContain("maxSpawnAttempts", json);
}
finally
{
original?.Delete();
}
}
[Fact]
public void RegionSpawner_RoundTrips_RegionByName()
{
var region = new BaseRegion("DtoTestRegion", Map.Felucca, 50, new Rectangle3D(1400, 1670, 0, 40, 40, 0));
region.Register();
RegionSpawner original = null;
BaseSpawner rebuilt = null;
try
{
original = new RegionSpawner("Fisherman") { SpawnRegion = region };
original.MoveToWorld(new Point3D(1416, 1683, 0), Map.Felucca);
var json = JsonSerializer.Serialize(new List<SpawnerDto> { original.ToDto() }, SpawnerJsonSerializer.Options);
Assert.Contains("\"$type\": \"RegionSpawner\"", json);
Assert.Contains("DtoTestRegion", json);
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(json, SpawnerJsonSerializer.Options);
rebuilt = Assert.Single(dtos).ToSpawner();
Assert.Equal("DtoTestRegion", Assert.IsType<RegionSpawner>(rebuilt).SpawnRegion?.Name);
}
finally
{
rebuilt?.Delete();
original?.Delete();
region.Unregister();
}
}
[Fact]
public void ProximitySpawner_RoundTrips_Fields()
{
ProximitySpawner original = null;
BaseSpawner rebuilt = null;
try
{
original = new ProximitySpawner("Fisherman") { TriggerRange = 4, InstantFlag = true, SpawnMessage = 500000 };
original.MoveToWorld(new Point3D(120, 120, 0), Map.Felucca);
var json = JsonSerializer.Serialize(new List<SpawnerDto> { original.ToDto() }, SpawnerJsonSerializer.Options);
Assert.Contains("\"$type\": \"ProximitySpawner\"", json);
Assert.Contains("\"triggerRange\": 4", json);
Assert.Contains("\"instant\": true", json);
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(json, SpawnerJsonSerializer.Options);
rebuilt = Assert.Single(dtos).ToSpawner();
var p = Assert.IsType<ProximitySpawner>(rebuilt);
Assert.Equal(4, p.TriggerRange);
Assert.True(p.InstantFlag);
Assert.Equal(500000, p.SpawnMessage.Number);
}
finally
{
rebuilt?.Delete();
original?.Delete();
}
}
}

View file

@ -1,58 +0,0 @@
using System;
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 SpawnerRoundTripTests
{
private static Map Map => Map.Felucca;
[Fact]
public void Spawner_RoundTrips_TypeAndCoreFields()
{
var spawner = new Spawner(2, TimeSpan.FromMinutes(3), TimeSpan.FromMinutes(7), 0,
new Rectangle3D(100, 100, 0, 5, 5, 0), "Fisherman");
spawner.MoveToWorld(new Point3D(105, 105, 0), Map);
var json = JsonSerializer.Serialize<List<BaseSpawner>>(
new List<BaseSpawner> { spawner }, SpawnerJsonSerializer.Options);
Assert.Contains("\"$type\": \"Spawner\"", json);
Assert.Contains("\"count\": 2", json);
var roundTripped = JsonSerializer.Deserialize<List<BaseSpawner>>(json, SpawnerJsonSerializer.Options);
var s = Assert.IsType<Spawner>(Assert.Single(roundTripped));
Assert.Equal(2, s.Count);
Assert.Equal(TimeSpan.FromMinutes(3), s.MinDelay);
Assert.Equal(TimeSpan.FromMinutes(7), s.MaxDelay);
Assert.Equal(new Rectangle3D(100, 100, 0, 5, 5, 0), s.SpawnBounds);
Assert.Single(s.Entries);
Assert.Equal("Fisherman", s.Entries[0].SpawnedName);
s.Delete();
spawner.Delete();
}
[Fact]
public void Spawner_OmitsDomainDefaults()
{
// Default delays (5/10 min), team 0, default maxSpawnAttempts → omitted.
var spawner = new Spawner("Fisherman");
spawner.MoveToWorld(new Point3D(110, 110, 0), Map);
var json = JsonSerializer.Serialize<List<BaseSpawner>>(
new List<BaseSpawner> { spawner }, SpawnerJsonSerializer.Options);
Assert.DoesNotContain("minDelay", json);
Assert.DoesNotContain("maxDelay", json);
Assert.DoesNotContain("\"team\"", json);
Assert.DoesNotContain("maxSpawnAttempts", json);
spawner.Delete();
}
}

View file

@ -0,0 +1,93 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BaseSpawner.Dto.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;
namespace Server.Engines.Spawners;
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();
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)
{
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
);
}
InitSpawn(dto.Count, dto.MinDelay ?? DefaultMinDelay, dto.MaxDelay ?? DefaultMaxDelay, dto.Team ?? 0, SpawnBounds);
_walkingRange = dto.WalkingRange ?? -1;
_spawnLocationIsHome = dto.SpawnLocationIsHome ?? false;
_spawnPositionMode = dto.SpawnPositionMode ?? SpawnPositionMode.Automatic;
_maxSpawnAttempts = dto.MaxSpawnAttempts ?? DefaultMaxSpawnAttempts;
if (dto.Entries != null)
{
for (var i = 0; i < dto.Entries.Count; i++)
{
var entry = dto.Entries[i];
AddEntry(entry.SpawnedName, entry.SpawnedProbability, entry.SpawnedMaxCount, false, entry.Properties, entry.Parameters);
}
}
}
// Export helpers — nullable so WhenWritingNull omits domain defaults, matching legacy ToJson.
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 SpawnPositionMode? DtoSpawnPositionMode =>
_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned
? _spawnPositionMode
: null;
// 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;
}

View file

@ -1,229 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BaseSpawner.Json.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.Collections.Generic;
using System.Text.Json.Serialization;
namespace Server.Engines.Spawners;
public abstract partial class BaseSpawner
{
// Transient import-only state (NOT [SerializableField]; never part of the binary save).
private Guid _jsonGuid;
private bool _jsonHasGuid;
private int _jsonCount;
private TimeSpan _jsonMinDelay = DefaultMinDelay;
private TimeSpan _jsonMaxDelay = DefaultMaxDelay;
private int _jsonTeam;
private int _jsonWalkingRange = -1;
private int _jsonHomeRange = -1;
private bool _jsonSpawnLocationIsHome;
private SpawnPositionMode _jsonSpawnPositionMode;
private int _jsonMaxSpawnAttempts = DefaultMaxSpawnAttempts;
private List<SpawnerEntry> _jsonEntries;
private protected Point3D _jsonLocation;
private protected Map _jsonMap;
// --- Import-placement accessors (not serialized; read by ImportSpawnersCommand after STJ deserialization) ---
[JsonIgnore]
public Point3D ImportLocation => _jsonLocation;
[JsonIgnore]
public Map ImportMap => _jsonMap;
// --- Always-written fields ---
[JsonInclude]
[JsonPropertyName("guid")]
public Guid JsonGuid
{
get => _guid;
set
{
_jsonGuid = value;
_jsonHasGuid = true;
}
}
[JsonInclude]
[JsonPropertyName("location")]
public Point3D JsonLocation
{
get => Location;
set => _jsonLocation = value;
}
[JsonInclude]
[JsonPropertyName("map")]
public Map JsonMap
{
get => Map;
set => _jsonMap = value;
}
[JsonInclude]
[JsonPropertyName("count")]
public int JsonCount
{
get => Count;
set => _jsonCount = value;
}
[JsonInclude]
[JsonPropertyName("entries")]
public List<SpawnerEntry> JsonEntries
{
get => Entries;
set => _jsonEntries = value;
}
// --- Conditionally-written fields (null getter => omitted under WhenWritingNull) ---
[JsonInclude]
[JsonPropertyName("name")]
public string JsonName
{
get => string.IsNullOrEmpty(Name) ? null : Name;
set => Name = value;
}
[JsonInclude]
[JsonPropertyName("minDelay")]
public TimeSpan? JsonMinDelay
{
get => _minDelay == DefaultMinDelay ? null : _minDelay;
set => _jsonMinDelay = value ?? DefaultMinDelay;
}
[JsonInclude]
[JsonPropertyName("maxDelay")]
public TimeSpan? JsonMaxDelay
{
get => _maxDelay == DefaultMaxDelay ? null : _maxDelay;
set => _jsonMaxDelay = value ?? DefaultMaxDelay;
}
[JsonInclude]
[JsonPropertyName("team")]
public int? JsonTeam
{
get => _team == 0 ? null : _team;
set => _jsonTeam = value ?? 0;
}
// Mirrors today's ToJson exactly: written when _walkingRange != 0, emitting the WalkingRange property.
[JsonInclude]
[JsonPropertyName("walkingRange")]
public int? JsonWalkingRange
{
get => _walkingRange != 0 ? WalkingRange : null;
set => _jsonWalkingRange = value ?? -1;
}
[JsonInclude]
[JsonPropertyName("spawnLocationIsHome")]
public bool? JsonSpawnLocationIsHome
{
get => _spawnLocationIsHome ? true : null;
set => _jsonSpawnLocationIsHome = value ?? false;
}
[JsonInclude]
[JsonPropertyName("spawnPositionMode")]
public SpawnPositionMode? JsonSpawnPositionMode
{
get => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned
? _spawnPositionMode
: null;
set => _jsonSpawnPositionMode = value ?? SpawnPositionMode.Automatic;
}
[JsonInclude]
[JsonPropertyName("maxSpawnAttempts")]
public int? JsonMaxSpawnAttempts
{
// _maxSpawnAttempts == 0 means "unset; use the default". GetSpawnPosition treats
// both 0 and DefaultMaxSpawnAttempts identically, so omit both from JSON output.
get => _maxSpawnAttempts > 0 && _maxSpawnAttempts != DefaultMaxSpawnAttempts ? _maxSpawnAttempts : null;
set => _jsonMaxSpawnAttempts = value ?? DefaultMaxSpawnAttempts;
}
// Legacy read-only: present in old files; converted in OnAfterJsonDeserialize. Never written
// (getter always null) — modern files carry spawnBounds instead.
[JsonInclude]
[JsonPropertyName("homeRange")]
public int? JsonHomeRange
{
get => null;
set => _jsonHomeRange = value ?? -1;
}
/// <summary>
/// Applies the deserialized JSON state to this live spawner. Fired by the resolver's
/// OnDeserialized after all shadow properties are set. Overrides MUST call base first.
/// This replaces the former (DynamicJson, options) constructor body.
/// </summary>
protected internal virtual void OnAfterJsonDeserialize()
{
if (_jsonHasGuid)
{
_guid = _jsonGuid;
}
// Legacy homeRange -> spawnBounds (Map not available yet; use the deserialized location).
if (_jsonHomeRange >= 0)
{
int z;
int depth;
if (_jsonHomeRange == 0)
{
z = _jsonLocation.Z;
depth = 0;
}
else
{
z = -128;
depth = 256;
}
SpawnBounds = new Rectangle3D(
_jsonLocation.X - _jsonHomeRange,
_jsonLocation.Y - _jsonHomeRange,
z,
_jsonHomeRange * 2 + 1,
_jsonHomeRange * 2 + 1,
depth
);
}
InitSpawn(_jsonCount, _jsonMinDelay, _jsonMaxDelay, _jsonTeam, SpawnBounds);
_walkingRange = _jsonWalkingRange;
_spawnLocationIsHome = _jsonSpawnLocationIsHome;
_spawnPositionMode = _jsonSpawnPositionMode;
_maxSpawnAttempts = _jsonMaxSpawnAttempts;
if (_jsonEntries != null)
{
for (var i = 0; i < _jsonEntries.Count; i++)
{
var entry = _jsonEntries[i];
AddEntry(entry.SpawnedName, entry.SpawnedProbability, entry.SpawnedMaxCount, false, entry.Properties, entry.Parameters);
}
}
}
}

View file

@ -120,6 +120,9 @@ public abstract partial class BaseSpawner : Item, ISpawner
[CommandProperty(AccessLevel.Developer)]
public abstract Rectangle3D SpawnBounds { get; set; }
/// <summary>Builds the JSON DTO for this spawner (export path).</summary>
public abstract SpawnerDto ToDto();
/// <summary>
/// If true, the home location of the spawn is the location where it spawned
/// If false, the home location of the spawn is the location of the spawner

View file

@ -66,7 +66,7 @@ public class ExportSpawnersCommand : BaseCommand
NetState.FlushAll();
var spawnRecords = new List<BaseSpawner>(list.Count);
var spawnRecords = new List<SpawnerDto>(list.Count);
for (var i = 0; i < list.Count; i++)
{
// Not a spawner, not on a valid map, or is in a container
@ -83,7 +83,7 @@ public class ExportSpawnersCommand : BaseCommand
continue;
}
spawnRecords.Add(spawner);
spawnRecords.Add(spawner.ToDto());
}
if (spawnRecords.Count == 0)

View file

@ -207,10 +207,11 @@ public static class ImportSpawnersCommand
ref int totalFailures
)
{
List<BaseSpawner> spawners;
List<SpawnerDto> dtos;
try
{
spawners = JsonConfig.Deserialize<List<BaseSpawner>>(file.FullName, SpawnerJsonSerializer.Options);
// DTO deserialization constructs NO world objects — a malformed file fails as GC only.
dtos = JsonConfig.Deserialize<List<SpawnerDto>>(file.FullName, SpawnerJsonSerializer.Options);
}
catch (JsonException)
{
@ -220,7 +221,7 @@ public static class ImportSpawnersCommand
return;
}
if (spawners == null || spawners.Count == 0)
if (dtos == null || dtos.Count == 0)
{
from?.SendMessage($"GenerateSpawners: Skipping empty spawner file {file.Name}");
logger.Information("{User} is skipping empty spawner file {File}", from, file.FullName);
@ -228,16 +229,27 @@ public static class ImportSpawnersCommand
}
using var queue = PooledRefQueue<Item>.Create();
for (var i = 0; i < spawners.Count; i++)
for (var i = 0; i < dtos.Count; i++)
{
var spawner = spawners[i];
var location = spawner.ImportLocation;
var map = spawner.ImportMap;
var dto = dtos[i];
var location = dto.Location;
var map = dto.Map;
if (map == null || map == Map.Internal)
{
logger.Error("Spawner {Guid} ({Index}) has no valid map; skipping.", spawner.Guid, i);
spawner.Delete();
logger.Error("Spawner {Guid} ({Index}) has no valid map; skipping.", dto.Guid, i);
totalFailures++;
continue;
}
BaseSpawner spawner;
try
{
spawner = dto.ToSpawner(); // constructs the single Item, now referenced
}
catch (Exception ex)
{
TraceException(ex, $"Failed to build spawner {dto.Guid}.");
totalFailures++;
continue;
}

View file

@ -0,0 +1,116 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: SpawnerDto.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.Collections.Generic;
using System.Text.Json.Serialization;
using Server.Json;
using Server.Regions;
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.
/// </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; }
/// <summary>Constructs the empty concrete spawner Item for this DTO.</summary>
protected abstract BaseSpawner CreateEmpty();
/// <summary>Builds and populates the live spawner. Override to apply subtype fields after base.</summary>
public virtual BaseSpawner ToSpawner()
{
var spawner = CreateEmpty();
spawner.ApplyDto(this);
return spawner;
}
}
[JsonDiscoverableType("Spawner")]
public sealed record SpawnerDataDto : SpawnerDto
{
[JsonPropertyName("spawnBounds")] public Rectangle3D? SpawnBounds { get; init; }
protected override BaseSpawner CreateEmpty() => new Spawner();
public override BaseSpawner ToSpawner()
{
var spawner = (Spawner)base.ToSpawner();
if (SpawnBounds is { } bounds && bounds != default)
{
spawner.SpawnBounds = bounds;
}
return spawner;
}
}
[JsonDiscoverableType("RegionSpawner")]
public sealed record RegionSpawnerDto : SpawnerDto
{
[JsonPropertyName("region")] public string Region { get; init; }
protected override BaseSpawner CreateEmpty() => new RegionSpawner();
public override BaseSpawner ToSpawner()
{
var spawner = (RegionSpawner)base.ToSpawner();
spawner.SpawnRegion = Server.Region.Find(Region, Map) as BaseRegion;
return spawner;
}
}
[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; }
protected override BaseSpawner CreateEmpty() => new ProximitySpawner();
public override BaseSpawner ToSpawner()
{
var spawner = (ProximitySpawner)base.ToSpawner();
if (SpawnBounds is { } bounds && bounds != default)
{
spawner.SpawnBounds = bounds;
}
spawner.TriggerRange = TriggerRange;
spawner.SpawnMessage = SpawnMessage;
spawner.InstantFlag = Instant;
return spawner;
}
}

View file

@ -2,7 +2,7 @@
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: ProximitySpawner.Json.cs *
* File: ProximitySpawner.Dto.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 *
@ -13,46 +13,28 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Text.Json.Serialization;
namespace Server.Engines.Spawners;
public partial class ProximitySpawner
{
private int _jsonTriggerRange;
private TextDefinition _jsonSpawnMessage;
private bool _jsonInstant;
[JsonInclude]
[JsonPropertyName("triggerRange")]
public int JsonTriggerRange
public override SpawnerDto ToDto() => new ProximitySpawnerDto
{
get => TriggerRange;
set => _jsonTriggerRange = value;
}
[JsonInclude]
[JsonPropertyName("spawnMessage")]
public TextDefinition JsonSpawnMessage
{
get => SpawnMessage;
set => _jsonSpawnMessage = value;
}
[JsonInclude]
[JsonPropertyName("instant")]
public bool JsonInstant
{
get => InstantFlag;
set => _jsonInstant = value;
}
protected internal override void OnAfterJsonDeserialize()
{
base.OnAfterJsonDeserialize();
TriggerRange = _jsonTriggerRange;
SpawnMessage = _jsonSpawnMessage;
InstantFlag = _jsonInstant;
}
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
};
}

View file

@ -22,7 +22,6 @@ using Server.Mobiles;
namespace Server.Engines.Spawners;
[SerializationGenerator(0)]
[JsonDiscoverableType]
public partial class ProximitySpawner : Spawner
{
[SerializableField(0)]
@ -38,7 +37,6 @@ public partial class ProximitySpawner : Spawner
private bool _instantFlag;
[Constructible(AccessLevel.Developer)]
[System.Text.Json.Serialization.JsonConstructor]
public ProximitySpawner()
{
}

View file

@ -2,7 +2,7 @@
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: RegionSpawner.Json.cs *
* File: RegionSpawner.Dto.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 *
@ -13,29 +13,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Text.Json.Serialization;
using Server.Regions;
namespace Server.Engines.Spawners;
public partial class RegionSpawner
{
private string _jsonRegion;
[JsonInclude]
[JsonPropertyName("region")]
public string JsonRegion
public override SpawnerDto ToDto() => new RegionSpawnerDto
{
get => SpawnRegion?.Name;
set => _jsonRegion = value;
}
protected internal override void OnAfterJsonDeserialize()
{
base.OnAfterJsonDeserialize();
_spawnRegion = Region.Find(_jsonRegion, _jsonMap) as BaseRegion;
_spawnRegion?.InitRectangles();
SpawnRegionName = _spawnRegion?.Name;
}
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,
Region = SpawnRegion?.Name
};
}

View file

@ -22,7 +22,6 @@ using Server.Regions;
namespace Server.Engines.Spawners;
[SerializationGenerator(0)]
[JsonDiscoverableType]
public partial class RegionSpawner : Spawner
{
[SerializableField(0, getter: "private", setter: "private")]
@ -33,7 +32,6 @@ public partial class RegionSpawner : Spawner
public override Region Region => _spawnRegion;
[Constructible(AccessLevel.Developer)]
[System.Text.Json.Serialization.JsonConstructor]
public RegionSpawner()
{
}

View file

@ -2,7 +2,7 @@
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: Spawner.Json.cs *
* File: Spawner.Dto.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 *
@ -13,29 +13,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Text.Json.Serialization;
namespace Server.Engines.Spawners;
public partial class Spawner
{
private Rectangle3D _jsonSpawnBounds;
[JsonInclude]
[JsonPropertyName("spawnBounds")]
public Rectangle3D? JsonSpawnBounds
public override SpawnerDto ToDto() => new SpawnerDataDto
{
get => _spawnBounds == default ? null : _spawnBounds;
set => _jsonSpawnBounds = value ?? default;
}
protected internal override void OnAfterJsonDeserialize()
{
base.OnAfterJsonDeserialize();
if (_jsonSpawnBounds != default)
{
SpawnBounds = _jsonSpawnBounds;
}
}
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
};
}

View file

@ -6,7 +6,6 @@ using Server.Json;
namespace Server.Engines.Spawners;
[SerializationGenerator(1)]
[JsonDiscoverableType]
public partial class Spawner : BaseSpawner
{
/// <summary>
@ -37,7 +36,6 @@ public partial class Spawner : BaseSpawner
}
[Constructible(AccessLevel.Developer)]
[System.Text.Json.Serialization.JsonConstructor]
public Spawner()
{
}

View file

@ -32,7 +32,7 @@ public static class SpawnerJsonSerializer
/// <summary>
/// Invoked automatically during the Configure bootstrap phase
/// (AssemblyHandler.Invoke("Configure")). Discovers every concrete BaseSpawner subclass
/// (AssemblyHandler.Invoke("Configure")). Discovers every concrete SpawnerDto subclass
/// marked with [JsonDiscoverableType] and registers it for STJ polymorphism.
/// </summary>
public static void Configure()
@ -58,7 +58,7 @@ public static class SpawnerJsonSerializer
for (var i = 0; i < types.Length; i++)
{
var type = types[i];
if (type.IsAbstract || !type.IsAssignableTo(typeof(BaseSpawner)))
if (type.IsAbstract || !type.IsAssignableTo(typeof(SpawnerDto)))
{
continue;
}
@ -73,8 +73,8 @@ public static class SpawnerJsonSerializer
if (!IsJsonConstructible(type))
{
throw new Exception(
$"Spawner type '{type.FullName}' is marked [JsonDiscoverableType] but System.Text.Json cannot construct it. " +
"Add a public parameterless constructor marked [JsonConstructor]."
$"SpawnerDto type '{type.FullName}' is marked [JsonDiscoverableType] but System.Text.Json cannot construct it. " +
"Add a public parameterless constructor or use a record with init-only properties."
);
}
@ -117,16 +117,14 @@ public static class SpawnerJsonSerializer
{
Modifiers =
{
AddPolymorphism,
PruneToJsonProperties,
AddOnDeserialized
AddPolymorphism
}
}
};
private static void AddPolymorphism(JsonTypeInfo typeInfo)
{
if (typeInfo.Type != typeof(BaseSpawner))
if (typeInfo.Type != typeof(SpawnerDto))
{
return;
}
@ -137,34 +135,4 @@ public static class SpawnerJsonSerializer
typeInfo.PolymorphismOptions.DerivedTypes.Add(_derivedTypes[i]);
}
}
// Spawners are Items with many public engine properties STJ would otherwise (de)serialize.
// Keep ONLY properties explicitly annotated with [JsonPropertyName] (our shadow properties).
private static void PruneToJsonProperties(JsonTypeInfo typeInfo)
{
if (!typeInfo.Type.IsAssignableTo(typeof(BaseSpawner)))
{
return;
}
for (var i = typeInfo.Properties.Count - 1; i >= 0; i--)
{
var provider = typeInfo.Properties[i].AttributeProvider;
var keep = provider?.IsDefined(typeof(JsonPropertyNameAttribute), true) ?? false;
if (!keep)
{
typeInfo.Properties.RemoveAt(i);
}
}
}
private static void AddOnDeserialized(JsonTypeInfo typeInfo)
{
if (!typeInfo.Type.IsAssignableTo(typeof(BaseSpawner)))
{
return;
}
typeInfo.OnDeserialized = static o => ((BaseSpawner)o).OnAfterJsonDeserialize();
}
}