refactor(spawners): replace DynamicJson with typed SpawnerDto records (#2505)
## Summary Removes the dated `DynamicJson` JSON helper and migrates spawner JSON (de)serialization to a polymorphic `record SpawnerDto` hierarchy. `DynamicJson` was the last remaining consumer (regions moved off it in #1400). The key correctness improvement: **System.Text.Json deserializes plain DTO records, never a live `Item`.** Previously, deserializing directly into an `Item` meant STJ constructed world-registered objects *before* the data was validated — a malformed/hand-edited spawn file could leave orphaned spawner Items in the world save. Now a parse failure is GC-only; `dto.ToSpawner()` constructs the spawner only from a fully-validated DTO and self-cleans on failure. ## What changed - **New:** `SpawnerDto` (abstract) + `SpawnerDataDto` / `RegionSpawnerDto` / `ProximitySpawnerDto`, each marked with a reusable `[JsonDiscoverableType]` opt-in attribute. Auto-discovered at the `Configure` phase — no manual registration list (avoids the regions `Register<T>()` footgun), open to custom spawner subtypes. - **Symmetric mapping:** `BaseSpawner.ToDto()` (export) ⇄ `SpawnerDto.ToSpawner()` (import). `ToSpawner()` deletes-and-rethrows on any failure, so the importer can never orphan an Item. - **`SpawnerJsonSerializer`** wires `$type` polymorphism on the `SpawnerDto` root with loud collision/constructibility validation. - **Import/export commands** rewired to the typed DTO path (reflection `FindTypeByName`/`CreateInstance` removed). - **Data migration:** the 109 `Distribution/Data/Spawns/**` files moved from `"type"`→`"$type"` and legacy `homeRange`→`spawnBounds`. The runtime still *reads* legacy `homeRange` for external files. The `homeRange→spawnBounds` formula is proven equivalent to the runtime conversion (`BoundsEquivalenceTests`, hr=0/1/3/7). - **Deleted:** `Projects/Server/Json/DynamicJson.cs`. Sparse export output matches the legacy `ToJson` (nullable DTO properties + `WhenWritingNull`). Binary world-save serialization is untouched. ## Tests - DTO round-trip per spawner type; sparse-default omission; legacy `homeRange` read; export/import file round-trip. - `Import_MalformedFile_LeaksNoWorldItems` — proves a mid-array parse failure constructs zero world Items. - `AllSpawnFilesLoadTests` — every migrated spawn file deserializes and builds. - Duplicate-discriminator validation. - UOContent.Tests 485/485, Server.Tests 710/710, build clean. ## Follow-up (not in this PR) `Rectangle3DConverter.Write` (in `Projects/Server/`) omits `z1/z2` when `Start.Z == -128`, so a future server-side export of a `homeRange`-style spawner round-trips depth 256→255. Pre-existing and out of scope here (Server change); the migrated data reads correctly. Worth a separate small converter PR.
This commit is contained in:
parent
a26219c837
commit
d8a64f3316
135 changed files with 7654 additions and 6079 deletions
|
|
@ -84,6 +84,7 @@ internal static class TestServerInitializer
|
|||
World.Load();
|
||||
World.ExitSerializationThreads();
|
||||
DecayScheduler.Configure();
|
||||
Server.Engines.Spawners.SpawnerJsonSerializer.Configure();
|
||||
|
||||
VerifyTrammelTileDataLoaded();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
// Regression test: verifies that EVERY migrated spawn file deserializes and builds spawners.
|
||||
// This catches any migration defects where a spawn file is syntactically correct JSON
|
||||
// but fails to build spawners via DTO.ToSpawner(). Any failure names the offending file
|
||||
// and is a real migration defect, not a test weakness.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Server;
|
||||
using Server.Engines.Spawners;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Engines.Spawners.Json;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class AllSpawnFilesLoadTests
|
||||
{
|
||||
[Fact]
|
||||
public void EverySpawnFile_DeserializesAndBuildsSpawners()
|
||||
{
|
||||
var root = Path.Combine(Core.BaseDirectory, "Data", "Spawns");
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
return; // distribution data not present in this checkout
|
||||
}
|
||||
|
||||
var failures = new List<string>();
|
||||
var fileCount = 0;
|
||||
var spawnerCount = 0;
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(root, "*.json", SearchOption.AllDirectories))
|
||||
{
|
||||
fileCount++;
|
||||
try
|
||||
{
|
||||
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(
|
||||
File.ReadAllText(file), SpawnerJsonSerializer.Options);
|
||||
|
||||
if (dtos != null)
|
||||
{
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spawner = dto.ToSpawner();
|
||||
spawnerCount++;
|
||||
spawner?.Delete();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add($"{file}: ToSpawner failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
failures.Add($"{file}: JSON deserialization failed: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add($"{file}: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
failures.Count == 0,
|
||||
$"Loaded {fileCount} files, built {spawnerCount} spawners. Failures:\n{string.Join("\n", failures)}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// Validates that the spawnBounds JSON emitted by tools/spawner-json-migrate/migrate.mjs
|
||||
// deserializes to the SAME Rectangle3D that the runtime homeRange path produces.
|
||||
// This test MUST pass before bulk-migrating the spawn data files.
|
||||
//
|
||||
// toBounds formula (from migrate.mjs):
|
||||
// hr == 0 -> { x1:x, y1:y, z1:z, x2:x+1, y2:y+1, z2:z }
|
||||
// hr > 0 -> { x1:x-hr, y1:y-hr, z1:-128, x2:x+hr+1, y2:y+hr+1, z2:128 }
|
||||
//
|
||||
// Rectangle3DConverter x1/y1/z1/x2/y2/z2 form creates Rectangle3D(Point3D(x1,y1,z1), Point3D(x2,y2,z2))
|
||||
// where _start==(x1,y1,z1) and _end==(x2,y2,z2). Rectangle3D.End is exclusive.
|
||||
|
||||
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 BoundsEquivalenceTests
|
||||
{
|
||||
private const int LocX = 100;
|
||||
private const int LocY = 200;
|
||||
private const int LocZ = 5;
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
[InlineData(7)]
|
||||
public void SpawnBoundsJson_EqualsHomeRangePath(int hr)
|
||||
{
|
||||
BaseSpawner fromHomeRange = null;
|
||||
BaseSpawner fromSpawnBounds = null;
|
||||
try
|
||||
{
|
||||
// Path A: legacy homeRange field -> ApplyDto sets SpawnBounds
|
||||
var homeRangeJson = BuildHomeRangeJson(hr);
|
||||
var dtosA = JsonSerializer.Deserialize<List<SpawnerDto>>(homeRangeJson, SpawnerJsonSerializer.Options);
|
||||
fromHomeRange = Assert.Single(dtosA).ToSpawner();
|
||||
var expected = Assert.IsType<Spawner>(fromHomeRange).SpawnBounds;
|
||||
|
||||
// Path B: migrated spawnBounds field -> SpawnerDataDto.ToSpawner sets SpawnBounds
|
||||
var spawnBoundsJson = BuildSpawnBoundsJson(hr);
|
||||
var dtosB = JsonSerializer.Deserialize<List<SpawnerDto>>(spawnBoundsJson, SpawnerJsonSerializer.Options);
|
||||
fromSpawnBounds = Assert.Single(dtosB).ToSpawner();
|
||||
var actual = Assert.IsType<Spawner>(fromSpawnBounds).SpawnBounds;
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fromHomeRange?.Delete();
|
||||
fromSpawnBounds?.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildHomeRangeJson(int hr) => $$"""
|
||||
[
|
||||
{
|
||||
"$type": "Spawner",
|
||||
"location": [{{LocX}}, {{LocY}}, {{LocZ}}],
|
||||
"map": "Felucca",
|
||||
"count": 1,
|
||||
"homeRange": {{hr}},
|
||||
"entries": []
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
private static string BuildSpawnBoundsJson(int hr)
|
||||
{
|
||||
int x1, y1, z1, x2, y2, z2;
|
||||
if (hr == 0)
|
||||
{
|
||||
// hr==0: _start=(x,y,z), _end=(x+1,y+1,z) — single tile, depth 0
|
||||
(x1, y1, z1, x2, y2, z2) = (LocX, LocY, LocZ, LocX + 1, LocY + 1, LocZ);
|
||||
}
|
||||
else
|
||||
{
|
||||
// hr>0: _start=(x-hr,y-hr,-128), _end=(x+hr+1,y+hr+1,128) — hr*2+1 wide, depth 256
|
||||
(x1, y1, z1, x2, y2, z2) = (LocX - hr, LocY - hr, -128, LocX + hr + 1, LocY + hr + 1, 128);
|
||||
}
|
||||
|
||||
return $$"""
|
||||
[
|
||||
{
|
||||
"$type": "Spawner",
|
||||
"location": [{{LocX}}, {{LocY}}, {{LocZ}}],
|
||||
"map": "Felucca",
|
||||
"count": 1,
|
||||
"spawnBounds": { "x1": {{x1}}, "y1": {{y1}}, "z1": {{z1}}, "x2": {{x2}}, "y2": {{y2}}, "z2": {{z2}} },
|
||||
"entries": []
|
||||
}
|
||||
]
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Server;
|
||||
using Server.Engines.Spawners;
|
||||
using Server.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Engines.Spawners.Json;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class ExportImportFileTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_ThenDeserialize_File_PreservesSpawner()
|
||||
{
|
||||
Spawner original = null;
|
||||
BaseSpawner rebuilt = null;
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rebuilt?.Delete();
|
||||
original?.Delete();
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Server;
|
||||
using Server.Engines.Spawners;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Engines.Spawners.Json;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class ImportCleanupTests
|
||||
{
|
||||
private const string SpawnerGuid = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Fact]
|
||||
public void Import_ValidFile_PlacesSpawner()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "test.json");
|
||||
File.WriteAllText(path, """
|
||||
[
|
||||
{
|
||||
"$type": "Spawner",
|
||||
"guid": "11111111-1111-1111-1111-111111111111",
|
||||
"location": [305, 305, 0],
|
||||
"map": "Felucca",
|
||||
"count": 1,
|
||||
"spawnBounds": { "x1": 300, "y1": 300, "x2": 310, "y2": 310 },
|
||||
"entries": []
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
BaseSpawner placedSpawner = null;
|
||||
try
|
||||
{
|
||||
var all = new Dictionary<Guid, ISpawner>();
|
||||
ImportSpawnersCommand.ImportFile(new FileInfo(path), all);
|
||||
|
||||
foreach (var s in Map.Felucca.GetItemsAt<BaseSpawner>(new Point3D(305, 305, 0)))
|
||||
{
|
||||
if (s.Guid == new Guid(SpawnerGuid))
|
||||
{
|
||||
placedSpawner = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotNull(placedSpawner);
|
||||
Assert.True(all.ContainsKey(new Guid(SpawnerGuid)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
placedSpawner?.Delete();
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_NoMap_DeletesOrphanedSpawner()
|
||||
{
|
||||
// A spawner with no map entry deserialized by STJ will have map == null (or Map.Internal).
|
||||
// Verify it does NOT end up on any live map.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-nomap-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "test-nomap.json");
|
||||
File.WriteAllText(path, """
|
||||
[
|
||||
{
|
||||
"$type": "Spawner",
|
||||
"guid": "22222222-2222-2222-2222-222222222222",
|
||||
"location": [400, 400, 0],
|
||||
"count": 1,
|
||||
"spawnBounds": { "x1": 395, "y1": 395, "x2": 405, "y2": 405 },
|
||||
"entries": []
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var all = new Dictionary<Guid, ISpawner>();
|
||||
ImportSpawnersCommand.ImportFile(new FileInfo(path), all);
|
||||
|
||||
// The spawner must NOT be in allSpawners (rejected due to missing map).
|
||||
Assert.DoesNotContain(new Guid("22222222-2222-2222-2222-222222222222"), all.Keys);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
// Verifies that an existing spawner at the same location and type is removed and replaced.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-dup-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "test-dup.json");
|
||||
|
||||
var newGuid = new Guid("33333333-3333-3333-3333-333333333333");
|
||||
// Use a non-interpolated raw string literal; the guid is a known constant so hardcode it.
|
||||
File.WriteAllText(path, """
|
||||
[
|
||||
{
|
||||
"$type": "Spawner",
|
||||
"guid": "33333333-3333-3333-3333-333333333333",
|
||||
"location": [310, 310, 0],
|
||||
"map": "Felucca",
|
||||
"count": 1,
|
||||
"spawnBounds": { "x1": 305, "y1": 305, "x2": 315, "y2": 315 },
|
||||
"entries": []
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
// Place a pre-existing spawner at the same location.
|
||||
var existing = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10));
|
||||
existing.MoveToWorld(new Point3D(310, 310, 0), Map.Felucca);
|
||||
|
||||
BaseSpawner placedSpawner = null;
|
||||
try
|
||||
{
|
||||
var all = new Dictionary<Guid, ISpawner> { [existing.Guid] = existing };
|
||||
ImportSpawnersCommand.ImportFile(new FileInfo(path), all);
|
||||
|
||||
// existing should have been deleted and replaced.
|
||||
Assert.True(existing.Deleted);
|
||||
Assert.DoesNotContain(existing.Guid, all.Keys);
|
||||
|
||||
foreach (var s in Map.Felucca.GetItemsAt<BaseSpawner>(new Point3D(310, 310, 0)))
|
||||
{
|
||||
if (s.Guid == newGuid)
|
||||
{
|
||||
placedSpawner = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotNull(placedSpawner);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!existing.Deleted)
|
||||
{
|
||||
existing.Delete();
|
||||
}
|
||||
placedSpawner?.Delete();
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
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 LegacyHomeRangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void HomeRange_NoSpawnBounds_ProducesCenteredBounds()
|
||||
{
|
||||
const string legacy = """
|
||||
[
|
||||
{
|
||||
"$type": "Spawner",
|
||||
"guid": "3df0543a-373c-4673-a98b-8191686f4ab3",
|
||||
"location": [100, 200, 5],
|
||||
"map": "Felucca",
|
||||
"count": 1,
|
||||
"homeRange": 3,
|
||||
"entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ]
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(legacy, SpawnerJsonSerializer.Options);
|
||||
var dto = Assert.Single(dtos);
|
||||
var s = Assert.IsType<Spawner>(dto.ToSpawner());
|
||||
|
||||
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]
|
||||
public void HomeRangeZero_ProducesSingleTileBounds()
|
||||
{
|
||||
const string legacy = """
|
||||
[
|
||||
{
|
||||
"$type": "Spawner",
|
||||
"location": [100, 200, 5],
|
||||
"map": "Felucca",
|
||||
"count": 1,
|
||||
"homeRange": 0,
|
||||
"entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ]
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(legacy, SpawnerJsonSerializer.Options);
|
||||
var dto = Assert.Single(dtos);
|
||||
var s = Assert.IsType<Spawner>(dto.ToSpawner());
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// Regression guard: verifies that a real migrated spawn file (post-uoml/felucca/Vendors.json)
|
||||
// can be deserialized as List<SpawnerDto> and each DTO produces a valid spawner via ToSpawner().
|
||||
// Exercises the $type discriminator path introduced by the data migration.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Server;
|
||||
using Server.Engines.Spawners;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Engines.Spawners.Json;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class MigratedDataLoadTests
|
||||
{
|
||||
[Fact]
|
||||
public void MigratedFile_LoadsWithDollarType()
|
||||
{
|
||||
var path = Path.Combine(Core.BaseDirectory, "Data", "Spawns", "post-uoml", "felucca", "Vendors.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return; // distribution data not present in this checkout
|
||||
}
|
||||
|
||||
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(File.ReadAllText(path), SpawnerJsonSerializer.Options);
|
||||
Assert.NotEmpty(dtos);
|
||||
|
||||
var spawners = new List<BaseSpawner>(dtos.Count);
|
||||
try
|
||||
{
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
spawners.Add(dto.ToSpawner());
|
||||
}
|
||||
|
||||
Assert.Equal(dtos.Count, spawners.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var s in spawners)
|
||||
{
|
||||
s?.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Server;
|
||||
using Server.Engines.Spawners;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Engines.Spawners.Json;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class SpawnerCompactWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeCompact_ProducesCompactLoadableFormat()
|
||||
{
|
||||
Spawner original = null;
|
||||
BaseSpawner rebuilt = null;
|
||||
try
|
||||
{
|
||||
original = new Spawner("Fisherman");
|
||||
original.MoveToWorld(new Point3D(200, 200, 0), Map.Felucca);
|
||||
original.SpawnBounds = new Rectangle3D(195, 195, -128, 11, 11, 256); // == homeRange 5
|
||||
|
||||
var json = SpawnerJsonSerializer.SerializeCompact(new List<SpawnerDto> { original.ToDto() });
|
||||
|
||||
// No BOM (StartsWith '[' proves it), UTF-8, LF, trailing newline.
|
||||
Assert.StartsWith("[", json);
|
||||
Assert.DoesNotContain("\r", json);
|
||||
Assert.EndsWith("]\n", json);
|
||||
|
||||
// $type first; scalar containers inline; entries (array of objects) expanded.
|
||||
Assert.Contains("\"$type\": \"Spawner\"", json);
|
||||
Assert.Contains("\"location\": [200, 200, 0]", json);
|
||||
Assert.Contains("\"homeRange\": 5", json);
|
||||
Assert.DoesNotContain("spawnBounds", json);
|
||||
Assert.Contains("\"entries\": [\n", json); // array of objects -> expanded
|
||||
Assert.Contains("{ \"name\": \"Fisherman\",", json); // each entry inline
|
||||
|
||||
// Round-trips back to an equivalent spawner.
|
||||
var dtos = JsonSerializer.Deserialize<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server.Engines.Spawners;
|
||||
using Server.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Engines.Spawners.Json;
|
||||
|
||||
public class SpawnerDiscoveryValidationTests
|
||||
{
|
||||
[JsonDiscoverableType("dup")]
|
||||
private sealed record DupA : SpawnerDto
|
||||
{
|
||||
protected override BaseSpawner CreateEmpty() => new Spawner();
|
||||
}
|
||||
|
||||
[JsonDiscoverableType("dup")]
|
||||
private sealed record DupB : SpawnerDto
|
||||
{
|
||||
protected override BaseSpawner CreateEmpty() => new Spawner();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateDiscriminator_Throws()
|
||||
{
|
||||
var map = new Dictionary<string, Type>();
|
||||
var (disc, _) = SpawnerJsonSerializer.Validate(typeof(DupA), map);
|
||||
map[disc] = typeof(DupA);
|
||||
|
||||
var ex = Assert.Throws<Exception>(() => SpawnerJsonSerializer.Validate(typeof(DupB), map));
|
||||
Assert.Contains("discriminator 'dup'", ex.Message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
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_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);
|
||||
|
||||
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();
|
||||
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_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);
|
||||
|
||||
// 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
|
||||
{
|
||||
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);
|
||||
Assert.DoesNotContain("homeRange", json);
|
||||
Assert.DoesNotContain("spawnBounds", 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue