ModernUO/Distribution/Data/Spawns/shared/ilshenar/Towns.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

677 lines
18 KiB
JSON

[
{
"$type": "Spawner",
"guid": "04236803-3dc7-426b-8a8d-d927b1bd0086",
"name": "Spawner (311)",
"location": [1279, 781, -80],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 5,
"walkingRange": 5,
"entries": [
{ "name": "Ridgeback", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "063240fa-7f32-4bad-b79e-bf9c8ca7b9b9",
"name": "Spawner (311)",
"location": [1108, 492, -80],
"map": "Ilshenar",
"count": 5,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "Hind", "maxCount": 2, "probability": 100 },
{ "name": "GreatHart", "maxCount": 1, "probability": 100 },
{ "name": "BlackBear", "maxCount": 1, "probability": 100 },
{ "name": "DireWolf", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "149dfca3-652c-4777-b005-a0cc95f30d9d",
"name": "Spawner (311)",
"location": [1517, 541, 11],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 20,
"entries": [
{ "name": "Lich", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "165f16c6-b66f-474a-aed6-0d6c2ee57123",
"name": "Spawner (311)",
"location": [1715, 228, 78],
"map": "Ilshenar",
"count": 6,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "StoneHarpy", "maxCount": 3, "probability": 100 },
{ "name": "StoneGargoyle", "maxCount": 2, "probability": 100 },
{ "name": "Balron", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "17243bc5-68af-437f-bb95-963913a05306",
"name": "Spawner (311)",
"location": [1191, 696, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "SavageRider", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "1b101011-42fa-41b1-b15c-f15fdcaaf6ec",
"name": "Spawner (311)",
"location": [1517, 558, 5],
"map": "Ilshenar",
"count": 6,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 35,
"entries": [
{ "name": "Skeleton", "maxCount": 6, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "1e1dd613-72d1-4715-b899-1086679533ab",
"name": "Spawner (311)",
"location": [1665, 244, 93],
"map": "Ilshenar",
"count": 5,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "EarthElemental", "maxCount": 3, "probability": 100 },
{ "name": "Drake", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "1e2bfa6b-abe9-4dcd-b996-bc4dcdd17f9f",
"name": "Spawner (311)",
"location": [1234, 760, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "SavageShaman", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "1f0236b1-b26e-4572-b03a-d53d94ab1a6a",
"name": "Spawner (311)",
"location": [1110, 542, -77],
"map": "Ilshenar",
"count": 5,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "Hind", "maxCount": 2, "probability": 100 },
{ "name": "GreatHart", "maxCount": 1, "probability": 100 },
{ "name": "BlackBear", "maxCount": 1, "probability": 100 },
{ "name": "DireWolf", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "26a0cc2e-3dfc-49d7-9274-d993fc0371db",
"name": "Spawner (311)",
"location": [1292, 755, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 4,
"walkingRange": 4,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "34ad74fe-d7eb-4427-8c7e-e7658bd63156",
"name": "Spawner (311)",
"location": [1363, 1046, -13],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 30,
"entries": [
{ "name": "EtherealWarrior", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "38614de6-1ed9-41a0-b30b-59ae8191316f",
"name": "Spawner (311)",
"location": [1213, 719, -80],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Savage", "maxCount": 3, "probability": 100 },
{ "name": "SavageRider", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "3bfd2dc7-d6d7-4604-858f-246ed9b25d35",
"name": "Spawner (311)",
"location": [1203, 762, -80],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Savage", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "4befab4e-1b1a-4290-ae76-6c176b48a18a",
"name": "Spawner (311)",
"location": [1046, 517, -80],
"map": "Ilshenar",
"count": 7,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 15,
"entries": [
{ "name": "SavageRider", "maxCount": 3, "probability": 100 },
{ "name": "Savage", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "56b2d766-8057-4400-913f-6c10a5155f98",
"name": "Spawner (311)",
"location": [1251, 703, -80],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Savage", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "59ae00b4-1b69-42de-8faa-670350014ad7",
"name": "Spawner (311)",
"location": [1116, 527, -80],
"map": "Ilshenar",
"count": 2,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 15,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 },
{ "name": "SavageRidgeback", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "5a5f79c6-3813-428e-a5fe-00893d247556",
"name": "Spawner (311)",
"location": [1161, 668, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 5,
"walkingRange": 5,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "5f1d23bd-e6c8-422e-b69e-a88d61fa61c0",
"name": "Spawner (311)",
"location": [1243, 732, -80],
"map": "Ilshenar",
"count": 5,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 },
{ "name": "SavageShaman", "maxCount": 3, "probability": 100 },
{ "name": "SavageRider", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "64e33b76-ba88-4906-a91f-4b56dea52a81",
"name": "Spawner (311)",
"location": [1181, 691, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 4,
"walkingRange": 4,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "6ac6b235-2d7c-4058-bc8d-d7f73cd17d5a",
"name": "Spawner (311)",
"location": [817, 1065, -30],
"map": "Ilshenar",
"count": 26,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 60,
"walkingRange": 70,
"entries": [
{ "name": "Ogre", "maxCount": 5, "probability": 100 },
{ "name": "OgreLord", "maxCount": 2, "probability": 100 },
{ "name": "Ratman", "maxCount": 10, "probability": 100 },
{ "name": "RatmanMage", "maxCount": 5, "probability": 100 },
{ "name": "RatmanArcher", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "6c8f4381-39be-4530-bfc3-1478035c1aa5",
"name": "Spawner (311)",
"location": [1108, 492, -80],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "Bird", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "72ce5891-f2dd-4a3c-8019-199928c49dcc",
"name": "Spawner (311)",
"location": [1231, 694, -80],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Savage", "maxCount": 2, "probability": 100 },
{ "name": "SavageShaman", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "7f13f3fa-2330-4dd5-9985-164d8e240659",
"name": "Spawner (311)",
"location": [1180, 1188, -25],
"map": "Ilshenar",
"count": 5,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 15,
"entries": [
{ "name": "Snake", "maxCount": 5, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "824d3710-eb67-4f27-8917-6debd7c8e2ca",
"name": "Spawner (311)",
"location": [1517, 552, 10],
"map": "Ilshenar",
"count": 6,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 30,
"entries": [
{ "name": "Spectre", "maxCount": 6, "probability": 100 },
{ "name": "Shade", "maxCount": 6, "probability": 100 },
{ "name": "Wraith", "maxCount": 6, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "86ff8b56-5d0c-4218-80d4-749414e3f568",
"name": "Spawner (311)",
"location": [1191, 696, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 5,
"walkingRange": 7,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "88f2279d-e351-4611-8255-206a456394d6",
"name": "Spawner (311)",
"location": [1110, 542, -77],
"map": "Ilshenar",
"count": 3,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "Bird", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "917aa704-acc1-49ce-81c6-401f0604b260",
"name": "Spawner (311)",
"location": [1274, 728, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "93476fd0-4859-4358-90ab-4371f8c1ac40",
"name": "Spawner (311)",
"location": [1181, 1189, -25],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 12,
"walkingRange": 12,
"entries": [
{ "name": "GiantSerpent", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "9b71629a-ede8-4fba-bd86-63a895c4c422",
"name": "Spawner (311)",
"location": [1214, 744, -80],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Savage", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "9d3ed0a0-e458-4fa9-bc94-008304706893",
"name": "Spawner (311)",
"location": [1516, 557, 4],
"map": "Ilshenar",
"count": 7,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 35,
"entries": [
{ "name": "Ghoul", "maxCount": 2, "probability": 100 },
{ "name": "Zombie", "maxCount": 5, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "9fce7b2d-3671-4d8e-bf22-19c10ec5709f",
"name": "Spawner (311)",
"location": [1280, 770, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 7,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "c4c688bb-cf62-4b3d-84bd-2633333c0242",
"name": "Spawner (311)",
"location": [1517, 552, 10],
"map": "Ilshenar",
"count": 6,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 30,
"entries": [
{ "name": "SkeletalKnight", "maxCount": 3, "probability": 100 },
{ "name": "BoneKnight", "maxCount": 3, "probability": 100 },
{ "name": "SkeletalMage", "maxCount": 3, "probability": 100 },
{ "name": "BoneMagi", "maxCount": 3, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "c671596f-69b6-45df-a106-b8fce71aa156",
"name": "Spawner (311)",
"location": [1517, 541, 61],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 4,
"walkingRange": 10,
"entries": [
{ "name": "LichLord", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "e0710f4e-9d44-4e1e-be0d-3f560d5f52c6",
"name": "Spawner (311)",
"location": [1643, 310, 48],
"map": "Ilshenar",
"count": 21,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 30,
"walkingRange": 50,
"entries": [
{ "name": "Phoenix", "maxCount": 2, "probability": 100 },
{ "name": "FireElemental", "maxCount": 4, "probability": 100 },
{ "name": "HellHound", "maxCount": 5, "probability": 100 },
{ "name": "LavaSerpent", "maxCount": 4, "probability": 100 },
{ "name": "LavaLizard", "maxCount": 4, "probability": 100 },
{ "name": "Daemon", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "eb0f7637-62d3-483f-835e-248e48ffdc44",
"name": "Spawner (311)",
"location": [1187, 685, -80],
"map": "Ilshenar",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 4,
"walkingRange": 4,
"entries": [
{ "name": "Savage", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "ed8774de-7f1f-4f40-bb03-0ff9e7d1ff31",
"name": "Spawner (311)",
"location": [1262, 758, -80],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "Savage", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "ef56cf38-1547-41ad-adc4-ba72aa156f87",
"name": "Spawner (311)",
"location": [1127, 1112, -34],
"map": "Ilshenar",
"count": 7,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 35,
"entries": [
{ "name": "DireWolf", "maxCount": 7, "probability": 100 },
{ "name": "TimberWolf", "maxCount": 7, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "f216ee7b-bb49-415a-aace-924f1778e3cc",
"name": "Spawner (311)",
"location": [1643, 310, 48],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 30,
"walkingRange": 50,
"entries": [
{ "name": "Succubus", "maxCount": 2, "probability": 100 },
{ "name": "Efreet", "maxCount": 2, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "fb0b433c-9d26-4a0f-b9a0-4ddb16ea1904",
"name": "Spawner (311)",
"location": [1046, 517, -80],
"map": "Ilshenar",
"count": 4,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 7,
"walkingRange": 10,
"entries": [
{ "name": "SavageShaman", "maxCount": 4, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "fb1de953-cfdd-46d6-aab9-68cbdfe60780",
"name": "Spawner (311)",
"location": [1203, 1138, -25],
"map": "Ilshenar",
"count": 16,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 40,
"walkingRange": 55,
"entries": [
{ "name": "MeerWarrior", "maxCount": 16, "probability": 100 },
{ "name": "MeerCaptain", "maxCount": 16, "probability": 100 },
{ "name": "MeerMage", "maxCount": 16, "probability": 100 },
{ "name": "MeerEternal", "maxCount": 16, "probability": 100 }
]
}
]