diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ExportImportFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ExportImportFileTests.cs index fa5b891b0..687942a7b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ExportImportFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ExportImportFileTests.cs @@ -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 { 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>(path, SpawnerJsonSerializer.Options); - var s = Assert.IsType(Assert.Single(loaded)); + JsonConfig.Serialize(path, new List { original.ToDto() }, SpawnerJsonSerializer.Options); + + var dtos = JsonConfig.Deserialize>(path, SpawnerJsonSerializer.Options); + rebuilt = Assert.Single(dtos).ToSpawner(); + var s = Assert.IsType(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(); } } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs index bbd4ba7e7..a9b7554fd 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs @@ -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()); + // 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(p)) + { + n++; + } + + return n; + } + [Fact] public void Import_DuplicateLocation_ReplacesExistingSpawner() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/LegacyHomeRangeTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/LegacyHomeRangeTests.cs index 9fb614938..90e66b353 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/LegacyHomeRangeTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/LegacyHomeRangeTests.cs @@ -26,13 +26,19 @@ public class LegacyHomeRangeTests ] """; - var rt = JsonSerializer.Deserialize>(legacy, SpawnerJsonSerializer.Options); - var s = Assert.IsType(Assert.Single(rt)); + var dtos = JsonSerializer.Deserialize>(legacy, SpawnerJsonSerializer.Options); + var dto = Assert.Single(dtos); + var s = Assert.IsType(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>(legacy, SpawnerJsonSerializer.Options); - var s = Assert.IsType(Assert.Single(rt)); + var dtos = JsonSerializer.Deserialize>(legacy, SpawnerJsonSerializer.Options); + var dto = Assert.Single(dtos); + var s = Assert.IsType(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(); + } } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ProximitySpawnerRoundTripTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ProximitySpawnerRoundTripTests.cs deleted file mode 100644 index 2f66e92d8..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ProximitySpawnerRoundTripTests.cs +++ /dev/null @@ -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>( - new List { spawner }, SpawnerJsonSerializer.Options); - Assert.Contains("\"$type\": \"ProximitySpawner\"", json); - Assert.Contains("\"triggerRange\": 4", json); - Assert.Contains("\"instant\": true", json); - - var rt = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); - s = Assert.IsType(Assert.Single(rt)); - Assert.Equal(4, s.TriggerRange); - Assert.True(s.InstantFlag); - Assert.Equal(500000, s.SpawnMessage.Number); - } - finally - { - s?.Delete(); - spawner?.Delete(); - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/RegionSpawnerRoundTripTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/RegionSpawnerRoundTripTests.cs deleted file mode 100644 index c24f2c8bb..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/RegionSpawnerRoundTripTests.cs +++ /dev/null @@ -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>( - new List { spawner }, SpawnerJsonSerializer.Options); - Assert.Contains("\"$type\": \"RegionSpawner\"", json); - Assert.Contains(region.Name, json); - - var rt = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); - s = Assert.IsType(Assert.Single(rt)); - Assert.Equal(region.Name, s.SpawnRegion?.Name); - } - finally - { - s?.Delete(); - spawner?.Delete(); - region.Unregister(); - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs new file mode 100644 index 000000000..25785d658 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs @@ -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 { original.ToDto() }, SpawnerJsonSerializer.Options); + Assert.Contains("\"$type\": \"Spawner\"", json); + Assert.Contains("\"count\": 2", json); + + var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); + rebuilt = Assert.Single(dtos).ToSpawner(); + var s = Assert.IsType(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 { 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 { original.ToDto() }, SpawnerJsonSerializer.Options); + Assert.Contains("\"$type\": \"RegionSpawner\"", json); + Assert.Contains("DtoTestRegion", json); + + var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); + rebuilt = Assert.Single(dtos).ToSpawner(); + Assert.Equal("DtoTestRegion", Assert.IsType(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 { original.ToDto() }, SpawnerJsonSerializer.Options); + Assert.Contains("\"$type\": \"ProximitySpawner\"", json); + Assert.Contains("\"triggerRange\": 4", json); + Assert.Contains("\"instant\": true", json); + + var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); + rebuilt = Assert.Single(dtos).ToSpawner(); + var p = Assert.IsType(rebuilt); + Assert.Equal(4, p.TriggerRange); + Assert.True(p.InstantFlag); + Assert.Equal(500000, p.SpawnMessage.Number); + } + finally + { + rebuilt?.Delete(); + original?.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerRoundTripTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerRoundTripTests.cs deleted file mode 100644 index fbe0a7eb2..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerRoundTripTests.cs +++ /dev/null @@ -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>( - new List { spawner }, SpawnerJsonSerializer.Options); - - Assert.Contains("\"$type\": \"Spawner\"", json); - Assert.Contains("\"count\": 2", json); - - var roundTripped = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); - var s = Assert.IsType(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>( - new List { spawner }, SpawnerJsonSerializer.Options); - - Assert.DoesNotContain("minDelay", json); - Assert.DoesNotContain("maxDelay", json); - Assert.DoesNotContain("\"team\"", json); - Assert.DoesNotContain("maxSpawnAttempts", json); - - spawner.Delete(); - } -} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs new file mode 100644 index 000000000..98bc81b40 --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs @@ -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 . * + *************************************************************************/ + +using System; + +namespace Server.Engines.Spawners; + +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(); + + 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; +} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs deleted file mode 100644 index 24d7551cd..000000000 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs +++ /dev/null @@ -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 . * - *************************************************************************/ - -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 _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 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; - } - - /// - /// 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. - /// - 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); - } - } - } -} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 529fe7f75..81f63175e 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -120,6 +120,9 @@ public abstract partial class BaseSpawner : Item, ISpawner [CommandProperty(AccessLevel.Developer)] public abstract Rectangle3D SpawnBounds { get; set; } + /// Builds the JSON DTO for this spawner (export path). + public abstract SpawnerDto ToDto(); + /// /// 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 diff --git a/Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs index 37feccc3a..24e0eb2ad 100644 --- a/Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs +++ b/Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs @@ -66,7 +66,7 @@ public class ExportSpawnersCommand : BaseCommand NetState.FlushAll(); - var spawnRecords = new List(list.Count); + var spawnRecords = new List(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) diff --git a/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs index d3fa38a5a..28dd7f613 100644 --- a/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs +++ b/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs @@ -207,10 +207,11 @@ public static class ImportSpawnersCommand ref int totalFailures ) { - List spawners; + List dtos; try { - spawners = JsonConfig.Deserialize>(file.FullName, SpawnerJsonSerializer.Options); + // DTO deserialization constructs NO world objects — a malformed file fails as GC only. + dtos = JsonConfig.Deserialize>(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.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; } diff --git a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs new file mode 100644 index 000000000..ef956c513 --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs @@ -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 . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Server.Json; +using Server.Regions; + +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. +/// +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; } + + /// Constructs the empty concrete spawner Item for this DTO. + protected abstract BaseSpawner CreateEmpty(); + + /// Builds and populates the live spawner. Override to apply subtype fields after base. + 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; + } +} diff --git a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Json.cs b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs similarity index 52% rename from Projects/UOContent/Engines/Spawners/ProximitySpawner.Json.cs rename to Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs index ad91755b6..feb6ec1a7 100644 --- a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Json.cs +++ b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs @@ -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 . * *************************************************************************/ -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 + }; } diff --git a/Projects/UOContent/Engines/Spawners/ProximitySpawner.cs b/Projects/UOContent/Engines/Spawners/ProximitySpawner.cs index 47f4f6a0d..90dae2d43 100644 --- a/Projects/UOContent/Engines/Spawners/ProximitySpawner.cs +++ b/Projects/UOContent/Engines/Spawners/ProximitySpawner.cs @@ -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() { } diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.Json.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs similarity index 64% rename from Projects/UOContent/Engines/Spawners/RegionSpawner.Json.cs rename to Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs index 0cbbb802b..3f15e25f3 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.Json.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs @@ -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 . * *************************************************************************/ -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 + }; } diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.cs index fe26cbe03..c88d774c5 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.cs @@ -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() { } diff --git a/Projects/UOContent/Engines/Spawners/Spawner.Json.cs b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs similarity index 63% rename from Projects/UOContent/Engines/Spawners/Spawner.Json.cs rename to Projects/UOContent/Engines/Spawners/Spawner.Dto.cs index 0600632a7..2b329e7bb 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.Json.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs @@ -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 . * *************************************************************************/ -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 + }; } diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index 1333f24f8..32bde4de1 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -6,7 +6,6 @@ using Server.Json; namespace Server.Engines.Spawners; [SerializationGenerator(1)] -[JsonDiscoverableType] public partial class Spawner : BaseSpawner { /// @@ -37,7 +36,6 @@ public partial class Spawner : BaseSpawner } [Constructible(AccessLevel.Developer)] - [System.Text.Json.Serialization.JsonConstructor] public Spawner() { } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs b/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs index 089ed3fe1..eefdf29fd 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs @@ -32,7 +32,7 @@ public static class SpawnerJsonSerializer /// /// 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. /// 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(); - } }