ModernUO/Distribution/Data/Spawns/post-uoml/termur/Abyss.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

420 lines
13 KiB
JSON

[
{
"$type": "Spawner",
"guid": "35fb2ebe-b2e6-4a64-af3a-ce1eaacd9a54",
"name": "Spawner (601)",
"location": [795, 753, 53],
"map": "TerMur",
"count": 8,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 15,
"entries": [
{ "name": "Hydra", "maxCount": 8, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "8d0bc27a-c900-4d7f-8811-a52f386b6d0c",
"name": "Spawner (601)",
"location": [745, 739, 16],
"map": "TerMur",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 5,
"walkingRange": 5,
"entries": [
{ "name": "MaddeningHorror", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "f7e9ab99-b970-4be1-881b-9e7ef1af0d01",
"name": "Spawner (601)",
"location": [749, 475, -17],
"map": "TerMur",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "SlasherOfVeils", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "dd5aea6e-c13b-40f4-949f-9f41eac96589",
"name": "Spawner (601)",
"location": [326, 159, 20],
"map": "TerMur",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 10,
"walkingRange": 10,
"entries": [
{ "name": "StygianDragon", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "5c81b759-1add-4e8a-b151-420aa778e236",
"name": "Spawner (601)",
"location": [716, 720, -11],
"map": "TerMur",
"count": 24,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "Snake", "maxCount": 24, "probability": 100 },
{ "name": "LavaSnake", "maxCount": 24, "probability": 100 },
{ "name": "CoralSnake", "maxCount": 24, "probability": 100 },
{ "name": "GiantSerpent", "maxCount": 24, "probability": 100 },
{ "name": "Coil", "maxCount": 24, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "a02bf178-9eb0-48ff-ae18-d06958f798a8",
"name": "Spawner (601)",
"location": [992, 337, 9],
"map": "TerMur",
"count": 35,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "GreaterMongbat", "maxCount": 35, "probability": 100 },
{ "name": "Imp", "maxCount": 35, "probability": 100 },
{ "name": "Daemon", "maxCount": 35, "probability": 100 },
{ "name": "PitFiend", "maxCount": 35, "probability": 100 },
{ "name": "DevourerRenowned", "maxCount": 35, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "3929df23-08d5-4650-89a1-6cb025fddbac",
"name": "Spawner (601)",
"location": [982, 491, -12],
"map": "TerMur",
"count": 15,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 18,
"walkingRange": 18,
"entries": [
{ "name": "ClanChitterAssistant", "maxCount": 15, "probability": 100 },
{ "name": "ClanChitterTinkerer", "maxCount": 15, "probability": 100 },
{ "name": "RakktaviRenowned", "maxCount": 15, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "941cbed7-bf9a-448d-ae6a-f741459f50d1",
"name": "Spawner (601)",
"location": [981, 491, -12],
"map": "TerMur",
"count": 16,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 18,
"walkingRange": 18,
"entries": [
{ "name": "ClockworkScorpion", "maxCount": 16, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "517bfdd3-f625-4e1c-af4b-8467810e6e46",
"name": "Spawner (601)",
"location": [919, 502, -12],
"map": "TerMur",
"count": 15,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "ClanRibbonSupplicant", "maxCount": 15, "probability": 100 },
{ "name": "ClanRibbonCourtier", "maxCount": 15, "probability": 100 },
{ "name": "VitaviRenowned", "maxCount": 15, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "592811d2-8196-4006-a774-5658b4df3ecb",
"name": "Spawner (601)",
"location": [918, 502, -12],
"map": "TerMur",
"count": 15,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "ClanRibbonPlagueRat", "maxCount": 15, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "8ff19011-9cc1-4aaf-a795-1867c6667123",
"name": "Spawner (601)",
"location": [949, 555, -14],
"map": "TerMur",
"count": 15,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "ClanScratchScrounger", "maxCount": 15, "probability": 100 },
{ "name": "ClanScratchHenchrat", "maxCount": 15, "probability": 100 },
{ "name": "TikitaviRenowned", "maxCount": 15, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "1b0b5739-68fc-4048-b2be-10da4dee13b8",
"name": "Spawner (601)",
"location": [950, 555, -14],
"map": "TerMur",
"count": 18,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "ClanScratchSavageWolf", "maxCount": 18, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "c33dadb8-e340-4fc6-9944-627054cf96f6",
"name": "Spawner (601)",
"location": [974, 161, -11],
"map": "TerMur",
"count": 18,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "FireAnt", "maxCount": 18, "probability": 100 },
{ "name": "LavaSnake", "maxCount": 18, "probability": 100 },
{ "name": "LavaLizard", "maxCount": 18, "probability": 100 },
{ "name": "FireGargoyle", "maxCount": 18, "probability": 100 },
{ "name": "Efreet", "maxCount": 18, "probability": 100 },
{ "name": "LavaElemental", "maxCount": 18, "probability": 100 },
{ "name": "FireDaemon", "maxCount": 18, "probability": 100 },
{ "name": "FireElementalRenowned", "maxCount": 18, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "dcc3ba35-0e14-48fe-8a1b-8a7e5b3e4c03",
"name": "Spawner (601)",
"location": [581, 815, -45],
"map": "TerMur",
"count": 30,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "EnslavedGrayGoblin", "maxCount": 30, "probability": 100 },
{ "name": "EnslavedGreenGoblin", "maxCount": 30, "probability": 100 },
{ "name": "EnslavedGoblinScout", "maxCount": 30, "probability": 100 },
{ "name": "EnslavedGoblinKeeper", "maxCount": 30, "probability": 100 },
{ "name": "EnslavedGreenGoblinAlchemist", "maxCount": 30, "probability": 100 },
{ "name": "EnslavedGoblinMage", "maxCount": 30, "probability": 100 },
{ "name": "GrayGoblinMageRenowned", "maxCount": 30, "probability": 100 },
{ "name": "GreenGoblinAlchemistRenowned", "maxCount": 30, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "6dfe8446-6623-4133-8990-6d11813b2b0d",
"name": "Spawner (601)",
"location": [526, 766, -92],
"map": "TerMur",
"count": 25,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "LavaLizard", "maxCount": 25, "probability": 100 },
{ "name": "LavaSnake", "maxCount": 25, "probability": 100 },
{ "name": "FireAnt", "maxCount": 25, "probability": 100 },
{ "name": "HellCat", "maxCount": 25, "probability": 100 },
{ "name": "LavaSerpent", "maxCount": 25, "probability": 100 },
{ "name": "HellHound", "maxCount": 25, "probability": 100 },
{ "name": "LavaElemental", "maxCount": 25, "probability": 100 },
{ "name": "FireDaemon", "maxCount": 25, "probability": 100 },
{ "name": "FireDaemonRenowned", "maxCount": 25, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "6d71c735-e0cb-414d-b687-10c1a4192856",
"name": "Spawner (601)",
"location": [536, 657, 8],
"map": "TerMur",
"count": 35,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 34,
"walkingRange": 34,
"entries": [
{ "name": "Skeleton", "maxCount": 35, "probability": 100 },
{ "name": "Wraith", "maxCount": 35, "probability": 100 },
{ "name": "SkeletalMage", "maxCount": 35, "probability": 100 },
{ "name": "WailingBanshee", "maxCount": 35, "probability": 100 },
{ "name": "SkeletalLich", "maxCount": 35, "probability": 100 },
{ "name": "RottingCorpse", "maxCount": 35, "probability": 100 },
{ "name": "AncientLichRenowned", "maxCount": 35, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "29d4421f-924d-47c3-abe5-45fe5e1be9a5",
"name": "Spawner (601)",
"location": [601, 905, -60],
"map": "TerMur",
"count": 38,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 35,
"walkingRange": 35,
"entries": [
{ "name": "LavaLizard", "maxCount": 38, "probability": 100 },
{ "name": "LavaSnake", "maxCount": 38, "probability": 100 },
{ "name": "FireAnt", "maxCount": 38, "probability": 100 },
{ "name": "HellHound", "maxCount": 38, "probability": 100 },
{ "name": "LavaSerpent", "maxCount": 38, "probability": 100 },
{ "name": "LavaElemental", "maxCount": 38, "probability": 100 },
{ "name": "FireDaemon", "maxCount": 38, "probability": 100 },
{ "name": "FireDaemonRenowned", "maxCount": 38, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "7dac51b3-7c0a-4f22-8652-7e29ba875fee",
"name": "Spawner (601)",
"location": [818, 927, -15],
"map": "TerMur",
"count": 1,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 15,
"walkingRange": 15,
"entries": [
{ "name": "Medusa", "maxCount": 1, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "c64053fc-272a-4fdd-9c6c-4f05a1ec02be",
"name": "Spawner (601)",
"location": [684, 579, -15],
"map": "TerMur",
"count": 32,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 35,
"walkingRange": 35,
"entries": [
{ "name": "CorrosiveSlime", "maxCount": 32, "probability": 100 },
{ "name": "AcidSlug", "maxCount": 32, "probability": 100 },
{ "name": "AcidElemental", "maxCount": 32, "probability": 100 },
{ "name": "InterredGrizzle", "maxCount": 32, "probability": 100 },
{ "name": "AcidElementalRenowned", "maxCount": 32, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "a72aa352-96b2-4163-83c0-de180a8fb7e8",
"name": "Spawner (601)",
"location": [440, 708, 24],
"map": "TerMur",
"count": 30,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 35,
"walkingRange": 35,
"entries": [
{ "name": "Pixie", "maxCount": 30, "probability": 100 },
{ "name": "Wisp", "maxCount": 30, "probability": 100 },
{ "name": "DarkWisp", "maxCount": 30, "probability": 100 },
{ "name": "PixieRenowned", "maxCount": 30, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "875d5be5-9156-4e42-a41a-973aef764aee",
"name": "Spawner (601)",
"location": [674, 828, -109],
"map": "TerMur",
"count": 30,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 25,
"walkingRange": 25,
"entries": [
{ "name": "Skeleton", "maxCount": 30, "probability": 100 },
{ "name": "PatchworkSkeleton", "maxCount": 30, "probability": 100 },
{ "name": "SkeletalKnight", "maxCount": 30, "probability": 100 },
{ "name": "BoneKnight", "maxCount": 30, "probability": 100 },
{ "name": "SkeletalMage", "maxCount": 30, "probability": 100 },
{ "name": "BoneMagi", "maxCount": 30, "probability": 100 },
{ "name": "SkeletalLich", "maxCount": 30, "probability": 100 },
{ "name": "SkeletalDragonRenowned", "maxCount": 30, "probability": 100 }
]
},
{
"$type": "Spawner",
"guid": "11551b29-0ee5-4fae-8d9b-2fd60465b032",
"name": "Spawner (601)",
"location": [887, 277, 3],
"map": "TerMur",
"count": 35,
"minDelay": "00:05:00",
"maxDelay": "00:10:00",
"team": 0,
"homeRange": 20,
"walkingRange": 20,
"entries": [
{ "name": "FairyDragon", "maxCount": 35, "probability": 100 },
{ "name": "Wyvern", "maxCount": 35, "probability": 100 },
{ "name": "ForgottenServant", "maxCount": 35, "probability": 100 },
{ "name": "WyvernRenowned", "maxCount": 35, "probability": 100 }
]
}
]