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:
Kamron Batman 2026-06-25 23:24:45 -07:00 • committed by GitHub
parent a26219c837
commit d8a64f3316
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
135 changed files with 7654 additions and 6079 deletions

View file

@ -1,6 +1,6 @@
[
[
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "0c48edf9-a88e-45f1-9ba3-cd8c919aefbc",
"name": "Spawner",
"location": [823, 2274, 0],
@ -14,7 +14,7 @@
"region": "A Cabbage Field in Skara Brae 2"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "0c6481ab-3341-4113-ac1a-6523c19c881d",
"name": "Spawner",
"location": [1216, 1604, 0],
@ -28,7 +28,7 @@
"region": "A Turnip Field in Britain 2"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "2bf378f0-77ed-44cd-8dfb-181d068f401e",
"name": "Spawner",
"location": [843, 2352, 0],
@ -42,7 +42,7 @@
"region": "A Wheatfield in Skara Brae 2"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "3d7925bf-2642-4185-bc03-fa314fb75a7f",
"name": "Spawner",
"location": [675, 1179, 0],
@ -56,7 +56,7 @@
"region": "A Field of Sheep in Yew 2"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "425ac125-1597-41b4-9e6d-72b91801230b",
"name": "Spawner",
"location": [675, 939, 0],
@ -70,7 +70,7 @@
"region": "A Field of Sheep in Yew 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "427451dd-93fa-43c9-85c6-01536066c8cc",
"name": "Spawner",
"location": [823, 2254, 0],
@ -84,7 +84,7 @@
"region": "A Carrot Field in Skara Brae"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "4b4e3973-d836-4f7c-b9f7-5fb7d468da28",
"name": "Spawner",
"location": [4567, 1475, 0],
@ -98,7 +98,7 @@
"region": "A Cotton Field in Moonglow"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "57da6436-6b16-4697-add5-f4b45776ccbc",
"name": "Spawner",
"location": [813, 2163, 0],
@ -112,7 +112,7 @@
"region": "A Wheatfield in Skara Brae 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "57f955bb-49e1-4a66-a214-4fbddf8d572b",
"name": "Spawner",
"location": [1231, 1723, 0],
@ -126,7 +126,7 @@
"region": "An Onion Field in Britain 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "58b14343-2032-4507-a913-6b536507c2e5",
"name": "Spawner",
"location": [1199, 1823, 0],
@ -140,7 +140,7 @@
"region": "A Wheatfield in Britain 2"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "640fe3f8-9917-4dfa-986d-038923163add",
"name": "Spawner",
"location": [823, 2264, 0],
@ -154,7 +154,7 @@
"region": "An Onion Field in Skara Brae"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "7b09d4b5-fe63-4f95-8525-3c970c513adc",
"name": "Spawner",
"location": [1232, 1604, 0],
@ -168,7 +168,7 @@
"region": "A Carrot Field in Britain 2"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "8536c19c-2036-495a-a3c5-e82c9f32f5ad",
"name": "Spawner",
"location": [1183, 1683, 0],
@ -182,7 +182,7 @@
"region": "A Cabbage Field in Britain 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "9805ef58-355f-4352-bfc4-43c6710ff409",
"name": "Spawner",
"location": [1215, 1723, 0],
@ -196,7 +196,7 @@
"region": "A Carrot Field in Britain 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "aed692cc-2dac-4c80-931b-35c73ca8d092",
"name": "Spawner",
"location": [571, 1099, 0],
@ -210,7 +210,7 @@
"region": "A Farm in Yew"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "b29b54ab-5ae3-4ce3-93ac-326f324a34f6",
"name": "Spawner",
"location": [1135, 1791, 0],
@ -224,7 +224,7 @@
"region": "A Wheatfield in Britain 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "bba64d97-11eb-4a87-b326-e41e3c9b355d",
"name": "Spawner",
"location": [375, 1191, 0],
@ -238,7 +238,7 @@
"region": "A Wheatfield in Yew 2"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "d8a2e54e-4951-468f-93c5-376460070cba",
"name": "Spawner",
"location": [1199, 1683, 0],
@ -252,7 +252,7 @@
"region": "A Turnip Field in Britain 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "e37b4d74-c87e-4102-8c87-8f80c728fe0c",
"name": "Spawner",
"location": [567, 1239, 0],
@ -266,7 +266,7 @@
"region": "A Wheatfield in Yew 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "e416e9fa-5a9a-426b-962e-50cdce64ca7f",
"name": "Spawner",
"location": [1152, 1576, 0],
@ -280,7 +280,7 @@
"region": "A Wheatfield in Britain 5"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "e6a4aede-5a76-473d-8331-4bb73701ea1a",
"name": "Spawner",
"location": [1120, 1624, 0],
@ -294,7 +294,7 @@
"region": "A Wheatfield in Britain 4"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "e7b34039-ec1f-43c8-b64f-d7462ce7ae9d",
"name": "Spawner",
"location": [823, 2355, 0],
@ -308,7 +308,7 @@
"region": "A Cotton Field in Skara Brae"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "f4bccdc2-c663-4ccd-b7df-31656fa2ae09",
"name": "Spawner",
"location": [823, 2264, 0],
@ -322,7 +322,7 @@
"region": "A Cabbage Field in Skara Brae 1"
},
{
"type": "RegionSpawner",
"$type": "RegionSpawner",
"guid": "f68b6bc8-f822-4c7a-9d01-37fad25bc95b",
"name": "Spawner",
"location": [1231, 1887, 0],