ModernUO/Distribution/Data/Spawns/shared/ilshenar/Ankh.json
Kamron Batman d8a64f3316
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.
2026-06-25 23:24:45 -07:00

489 lines
12 KiB
JSON

[
{
"$type": "Spawner",
"guid": "01be3c7a-f159-4721-aa38-5861f526d3b8",
"name": "Spawner (302)",
"location": [45, 905, -29],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 30,
"entries": [
{ "name": "FireElemental", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "1146a823-b233-4508-89f4-802dc5f67112",
"name": "Spawner (302)",
"location": [66, 1171, -28],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 5,
"walkingRange": 10,
"entries": [
{ "name": "Nightmare", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "12ed93c0-e81b-4de2-afe6-0c5c34975a41",
"name": "Spawner (302)",
"location": [49, 846, -30],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 12,
"walkingRange": 12,
"entries": [
{ "name": "PoisonElemental", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "192e009f-b329-47a3-b290-cd1af4504db6",
"name": "Spawner (302)",
"location": [148, 953, -29],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 15,
"entries": [
{ "name": "Kirin", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "250608fe-f32f-472e-a726-d5e1e3924160",
"name": "Spawner (302)",
"location": [15, 1503, -27],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 7,
"entries": [
{ "name": "Imp", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "2de1d85a-a211-4c0d-8cdb-3820a2f4cde3",
"name": "Spawner (302)",
"location": [62, 1446, -28],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 7,
"entries": [
{ "name": "EvilMage", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "32d38b45-94a0-429d-b06d-da2941222110",
"name": "Spawner (302)",
"location": [39, 1491, -28],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Daemon", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "40c9f7b2-9ce5-4c7c-ba9c-d78cbd9a7eab",
"name": "Spawner (302)",
"location": [131, 1331, -28],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "LichLord", "maxCount": 1, "probability": 100 },
{ "name": "RottingCorpse", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "5644d83b-87a0-46aa-a23d-5166d9d4f2f7",
"name": "Spawner (302)",
"location": [55, 1303, -28],
"map": "Ilshenar",
"count": 6,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 30,
"entries": [
{ "name": "Skeleton", "maxCount": 3, "probability": 100 },
{ "name": "SkeletalKnight", "maxCount": 2, "probability": 100 },
{ "name": "SkeletalMage", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "5c977889-217c-4562-bc51-d549a3ce616d",
"name": "Spawner (302)",
"location": [29, 1016, -27],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 35,
"entries": [
{ "name": "Drake", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "675f68f6-58e7-4a9c-adef-31aa0ecdbc00",
"name": "Spawner (302)",
"location": [86, 1447, -28],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 15,
"entries": [
{ "name": "Mummy", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "6fa3458d-ad0c-4ec0-a058-1d831df7b112",
"name": "Spawner (302)",
"location": [71, 1521, -28],
"map": "Ilshenar",
"count": 5,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 40,
"entries": [
{ "name": "HellHound", "maxCount": 2, "probability": 100 },
{ "name": "Imp", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "94a6eb04-35cb-49c0-8697-ac3caf736e1a",
"name": "Spawner (302)",
"location": [124, 1435, -16],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 20,
"entries": [
{ "name": "SkeletalMage", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "94b96b34-65e4-4af5-a3cd-56c2c8e8478f",
"name": "Spawner (302)",
"location": [473, 1552, -28],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 20,
"entries": [
{ "name": "SerpentineDragon", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "9a01a01c-2e9f-46ad-b9a9-0d1af7d3b211",
"name": "Spawner (302)",
"location": [102, 959, -43],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 20,
"entries": [
{ "name": "PoisonElemental", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "9bca331b-b283-436c-b46b-446d69e18593",
"name": "Spawner (302)",
"location": [108, 1110, -27],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 30,
"entries": [
{ "name": "EarthElemental", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "9debe6c0-b766-4c9a-94c7-2f4db1d1a7fd",
"name": "Spawner (302)",
"location": [34, 1462, -28],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "BloodElemental", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "a474135e-6997-45bf-a865-77ca9c5c854b",
"name": "Spawner (302)",
"location": [29, 1009, -27],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 35,
"entries": [
{ "name": "Dragon", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "b88fa850-e3c3-468e-b8e4-afc1430e5ad6",
"name": "Spawner (302)",
"location": [87, 1051, -29],
"map": "Ilshenar",
"count": 10,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 35,
"entries": [
{ "name": "EarthElemental", "maxCount": 3, "probability": 100 },
{ "name": "Mongbat", "maxCount": 7, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "c37e0854-914d-46a7-850f-4b8072add151",
"name": "Spawner (302)",
"location": [87, 1471, -22],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 7,
"entries": [
{ "name": "EvilMage", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "c5dbed97-1066-4bcc-8b21-cffeb33843ad",
"name": "Spawner (302)",
"location": [99, 1158, -23],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "AcidElemental", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "cb063844-675e-4177-9da9-7e3c1d2704af",
"name": "Spawner (302)",
"location": [63, 1471, -28],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 7,
"entries": [
{ "name": "EvilMage", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "e3b4ec7a-974a-42ef-b9be-80b93317c858",
"name": "Spawner (302)",
"location": [129, 1530, -28],
"map": "Ilshenar",
"count": 9,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 40,
"entries": [
{ "name": "Ratman", "maxCount": 5, "probability": 100 },
{ "name": "RatmanArcher", "maxCount": 2, "probability": 100 },
{ "name": "RatmanMage", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "e4279b1a-4375-4a93-9ed5-df2a8f9ade4a",
"name": "Spawner (302)",
"location": [438, 1552, -28],
"map": "Ilshenar",
"count": 5,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 20,
"entries": [
{ "name": "Pixie", "maxCount": 5, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "e73b0e45-8071-4f7a-8126-d3b05a6ac95e",
"name": "Spawner (302)",
"location": [114, 908, -26],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 25,
"entries": [
{ "name": "EarthElemental", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "e87e63f3-2c4e-4a95-ad84-7cae33a9071b",
"name": "Spawner (302)",
"location": [31, 1410, -28],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 11,
"entries": [
{ "name": "SkeletalKnight", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "ed586ce9-3e23-403c-b92a-013f2e88fb3c",
"name": "Spawner (302)",
"location": [107, 1390, -28],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Lich", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "f444295e-dedd-4070-8386-54b35d4ac4bb",
"name": "Spawner (302)",
"location": [451, 1552, -27],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 25,
"entries": [
{ "name": "Wisp", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "fc88bc1a-1266-4ae7-ad00-50ecb32896ff",
"name": "Spawner (302)",
"location": [124, 1436, -16],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 15,
"entries": [
{ "name": "Lich", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "fd6c1c50-ee38-412c-8957-3f74936e8020",
"name": "Spawner (302)",
"location": [132, 1332, -28],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "Zombie", "maxCount": 4, "probability": 100 }
]
}
]