## 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.
538 lines
14 KiB
JSON
538 lines
14 KiB
JSON
[
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "091c33d5-ee75-471b-aafc-78ea45d3dd66",
|
|
"name": "Spawner (309)",
|
|
"location": [336, 48, -28],
|
|
"map": "Ilshenar",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 8,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "Gargoyle", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "10f6b4be-fd86-480d-b285-d00f0f55b59c",
|
|
"name": "Spawner (309)",
|
|
"location": [155, 21, -28],
|
|
"map": "Ilshenar",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "Balron", "maxCount": 2, "probability": 100 },
|
|
{ "name": "ElderGazer", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "1fb85d78-33ef-42c5-8145-e0af0ec5bd33",
|
|
"name": "Spawner (309)",
|
|
"location": [336, 37, -28],
|
|
"map": "Ilshenar",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 8,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "Gargoyle", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "20ea3ede-9733-4b69-a2a9-44312ad782df",
|
|
"name": "Spawner (309)",
|
|
"location": [254, 20, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 5,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "BloodElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "271dd12c-8c89-4962-8766-27c6e23ab039",
|
|
"name": "Spawner (309)",
|
|
"location": [427, 95, -28],
|
|
"map": "Ilshenar",
|
|
"count": 6,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "Skeleton", "maxCount": 4, "probability": 100 },
|
|
{ "name": "BoneKnight", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "27540523-3186-413d-99da-3fd4ef5be98f",
|
|
"name": "Spawner (309)",
|
|
"location": [281, 20, -28],
|
|
"map": "Ilshenar",
|
|
"count": 7,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "ElderGazer", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Gazer", "maxCount": 4, "probability": 100 },
|
|
{ "name": "GazerLarva", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "29b9ab63-8072-4a0e-b8b2-cbdbe54a74c6",
|
|
"name": "Spawner (309)",
|
|
"location": [462, 16, -28],
|
|
"map": "Ilshenar",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 5,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "HeadlessOne", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Mongbat", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "37598c17-946c-4a0a-8e86-20d1329e4bca",
|
|
"name": "Spawner (309)",
|
|
"location": [133, 114, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 20,
|
|
"walkingRange": 25,
|
|
"entries": [
|
|
{ "name": "BoneKnight", "maxCount": 1, "probability": 100 },
|
|
{ "name": "SkeletalKnight", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "3aad6c47-815b-4713-93f2-c57156aded30",
|
|
"name": "Spawner (309)",
|
|
"location": [305, 20, -28],
|
|
"map": "Ilshenar",
|
|
"count": 5,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "DullCopperElemental", "maxCount": 5, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "492af6a5-b034-4a8b-aa7b-923b906321e2",
|
|
"name": "Spawner (309)",
|
|
"location": [133, 114, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "BloodElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "5bceb873-73d8-4326-8ec1-46bb48c65d60",
|
|
"name": "Spawner (309)",
|
|
"location": [214, 67, -28],
|
|
"map": "Ilshenar",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 15,
|
|
"walkingRange": 40,
|
|
"entries": [
|
|
{ "name": "Imp", "maxCount": 4, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "5e285fc0-52a0-4f23-ad8f-bc7e90b2fcec",
|
|
"name": "Spawner (309)",
|
|
"location": [455, 70, -28],
|
|
"map": "Ilshenar",
|
|
"count": 5,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 7,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "AcidElemental", "maxCount": 5, "probability": 100 },
|
|
{ "name": "FireElemental", "maxCount": 5, "probability": 100 },
|
|
{ "name": "BloodElemental", "maxCount": 5, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "6433de3a-a4f6-4d51-9249-0dc37a3fe107",
|
|
"name": "Spawner (309)",
|
|
"location": [394, 69, -28],
|
|
"map": "Ilshenar",
|
|
"count": 7,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "Shade", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Mongbat", "maxCount": 5, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "65f92121-fb34-4f86-9722-4ee64ec64b61",
|
|
"name": "Spawner (309)",
|
|
"location": [109, 44, -28],
|
|
"map": "Ilshenar",
|
|
"count": 7,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 15,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "BoneMagi", "maxCount": 4, "probability": 100 },
|
|
{ "name": "SkeletalMage", "maxCount": 4, "probability": 100 },
|
|
{ "name": "Mummy", "maxCount": 3, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "72c10d82-2c9a-489b-8bad-28dac3e794c9",
|
|
"name": "Spawner (309)",
|
|
"location": [447, 19, -28],
|
|
"map": "Ilshenar",
|
|
"count": 6,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 25,
|
|
"entries": [
|
|
{ "name": "Gargoyle", "maxCount": 3, "probability": 100 },
|
|
{ "name": "StoneGargoyle", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Lich", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Shade", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "73b4ab03-1cab-4034-a3c0-9b8ba61d3600",
|
|
"name": "Spawner (309)",
|
|
"location": [160, 59, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "BloodElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "73efa56f-434e-4fc0-8822-c028c9a83e6c",
|
|
"name": "Spawner (309)",
|
|
"location": [431, 16, -28],
|
|
"map": "Ilshenar",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 5,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "HeadlessOne", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Mongbat", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "807fd985-4f3a-4d1f-a7be-942294f1d782",
|
|
"name": "Spawner (309)",
|
|
"location": [238, 48, -28],
|
|
"map": "Ilshenar",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 7,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "Balron", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Daemon", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "818d30b9-0568-4e69-9b00-a8567e0f63b4",
|
|
"name": "Spawner (309)",
|
|
"location": [143, 70, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "EvilMageLord", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "846a8862-f62b-4814-856b-88a6f9cb0a27",
|
|
"name": "Spawner (309)",
|
|
"location": [350, 42, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "BloodElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "86f27a56-95cc-49ed-8483-38c07cd31de2",
|
|
"name": "Spawner (309)",
|
|
"location": [462, 16, -28],
|
|
"map": "Ilshenar",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 5,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "EarthElemental", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "94e38cf9-78c0-498b-927d-f7151001f811",
|
|
"name": "Spawner (309)",
|
|
"location": [67, 70, -28],
|
|
"map": "Ilshenar",
|
|
"count": 6,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 15,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "LichLord", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Mummy", "maxCount": 4, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "99674640-e132-4cdd-a69e-1607e28dc5f3",
|
|
"name": "Spawner (309)",
|
|
"location": [310, 92, -13],
|
|
"map": "Ilshenar",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 20,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "LichLord", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "9ce0e4a2-0318-4e8d-83a6-48933b302425",
|
|
"name": "Spawner (309)",
|
|
"location": [336, 42, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 8,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "Lich", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Shade", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "9cebea0f-1699-45af-a9b1-0d79d99d290e",
|
|
"name": "Spawner (309)",
|
|
"location": [383, 29, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 5,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "BloodElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "a1b6deba-e340-4851-bba7-4f5ad2d03353",
|
|
"name": "Spawner (309)",
|
|
"location": [146, 83, -28],
|
|
"map": "Ilshenar",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "BloodElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "a304cb5f-12b3-401a-96d3-104ee9f33544",
|
|
"name": "Spawner (309)",
|
|
"location": [311, 91, -13],
|
|
"map": "Ilshenar",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 20,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "PoisonElemental", "maxCount": 1, "probability": 100 },
|
|
{ "name": "BloodElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "b6f7c48e-faa6-44a6-b59f-d0b47313a4f6",
|
|
"name": "Spawner (309)",
|
|
"location": [278, 53, -28],
|
|
"map": "Ilshenar",
|
|
"count": 7,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "HellHound", "maxCount": 6, "probability": 100 },
|
|
{ "name": "BloodElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "bd576d60-5525-4ee3-b85f-8439e6dd38fd",
|
|
"name": "Spawner (309)",
|
|
"location": [160, 9, -28],
|
|
"map": "Ilshenar",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 4,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Efreet", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "bed076f5-fb2b-4342-9423-6a3228cc1978",
|
|
"name": "Spawner (309)",
|
|
"location": [262, 84, -28],
|
|
"map": "Ilshenar",
|
|
"count": 6,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 15,
|
|
"walkingRange": 15,
|
|
"entries": [
|
|
{ "name": "Gargoyle", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 },
|
|
{ "name": "EvilMage", "maxCount": 3, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "f4631bce-3338-4d0e-88c9-9faa4ba968ef",
|
|
"name": "Spawner (309)",
|
|
"location": [262, 84, -28],
|
|
"map": "Ilshenar",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 15,
|
|
"walkingRange": 25,
|
|
"entries": [
|
|
{ "name": "Shade", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Zombie", "maxCount": 1, "probability": 100 },
|
|
{ "name": "DreadSpider", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "fc22ceb7-01e2-4389-a356-ffc788071f0f",
|
|
"name": "Spawner (309)",
|
|
"location": [427, 67, -28],
|
|
"map": "Ilshenar",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 25,
|
|
"walkingRange": 25,
|
|
"entries": [
|
|
{ "name": "Zombie", "maxCount": 2, "probability": 100 }
|
|
]
|
|
}
|
|
]
|