## 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.
491 lines
13 KiB
JSON
491 lines
13 KiB
JSON
[
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "7bd2b3c7-3c39-40c5-9c3b-98b73d060c65",
|
|
"name": "Spawner (604)",
|
|
"location": [976, 3881, -42],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Baker", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "a80c5b72-e181-4266-8109-9c7a5eaf6a10",
|
|
"name": "Spawner (604)",
|
|
"location": [1010, 3883, -42],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Blacksmith", "maxCount": 1, "probability": 100 },
|
|
{ "name": "BlacksmithGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "99c47ee2-bbb0-4d0b-9294-18104ebb0f6b",
|
|
"name": "Spawner (604)",
|
|
"location": [1013, 3913, -42],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Healer", "maxCount": 1, "probability": 100 },
|
|
{ "name": "HealerGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "6b95fe1b-1c63-479d-b481-1b4d4c929a79",
|
|
"name": "Spawner (604)",
|
|
"location": [977, 3909, -42],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Tinker", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TinkerGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "9c632527-c639-469c-9ded-ca859e4a8859",
|
|
"name": "Spawner (604)",
|
|
"location": [943, 3924, -42],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "InnKeeper", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "b317c56d-c032-4e81-aa3a-78988500a740",
|
|
"name": "Spawner (604)",
|
|
"location": [1020, 3884, -42],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Blacksmith", "maxCount": 1, "probability": 100 },
|
|
{ "name": "BlacksmithGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "556fdb4d-f7f2-4ba0-9d01-d94a76ee7a28",
|
|
"name": "Spawner (604)",
|
|
"location": [740, 3466, -19],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "InnKeeper", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "65b19c79-0f40-4772-9037-1cad0769456d",
|
|
"name": "Spawner (604)",
|
|
"location": [772, 3467, -20],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Scribe", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "fd3342ae-de6c-436e-964a-e410ba1f983b",
|
|
"name": "Spawner (604)",
|
|
"location": [773, 3478, -20],
|
|
"map": "TerMur",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Herbalist", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Alchemist", "maxCount": 1, "probability": 100 },
|
|
{ "name": "CustomHairstylist", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "fcffbb1c-fb73-4c59-9257-52e1d6ebadb4",
|
|
"name": "Spawner (604)",
|
|
"location": [772, 3490, -20],
|
|
"map": "TerMur",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Mage", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Alchemist", "maxCount": 1, "probability": 100 },
|
|
{ "name": "MageGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "bb731ab1-950e-4e4b-8e55-6378eeb2c3b0",
|
|
"name": "Spawner (604)",
|
|
"location": [783, 3491, -20],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Healer", "maxCount": 1, "probability": 100 },
|
|
{ "name": "HealerGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "36f1b1bc-0754-4605-929a-d7c8e030a80b",
|
|
"name": "Spawner (604)",
|
|
"location": [797, 3494, 0],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Provisioner", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Cobbler", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "28cda9f4-bbd1-451d-acb0-f568361a7d6f",
|
|
"name": "Spawner (604)",
|
|
"location": [805, 3491, -20],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Baker", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "0bd1f793-51e9-4dbe-ab5c-dbd452e85d8c",
|
|
"name": "Spawner (604)",
|
|
"location": [817, 3456, -10],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Butcher", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "5f573458-bb63-4617-b2c4-c73762718dae",
|
|
"name": "Spawner (604)",
|
|
"location": [817, 3438, 0],
|
|
"map": "TerMur",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Carpenter", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Architect", "maxCount": 1, "probability": 100 },
|
|
{ "name": "RealEstateBroker", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "37378f9d-5e71-46a8-8d15-0b98d48a3d04",
|
|
"name": "Spawner (604)",
|
|
"location": [817, 3426, 0],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Tinker", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TinkerGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "2088913c-0ec7-4225-8f9d-1112e817542b",
|
|
"name": "Spawner (604)",
|
|
"location": [816, 3419, 0],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Blacksmith", "maxCount": 1, "probability": 100 },
|
|
{ "name": "BlacksmithGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "094d18b0-4b1a-491d-ae33-2463d400904b",
|
|
"name": "Spawner (604)",
|
|
"location": [805, 3397, 0],
|
|
"map": "TerMur",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Tailor", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Weaver", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TailorGuildmaster", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "b9c41d27-d185-41dc-aa13-7258c018abc1",
|
|
"name": "Spawner (604)",
|
|
"location": [804, 3387, 0],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Tanner", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Furtrader", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "720f9c6d-c0e6-4f66-aca9-cca448a5d02d",
|
|
"name": "Spawner (604)",
|
|
"location": [851, 3404, -20],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "AnimalTrainer", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "0197b002-1c6d-45ac-8d7d-21706a854586",
|
|
"name": "Spawner (604)",
|
|
"location": [833, 3439, -20],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Banker", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Minter", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "3e2450a8-7aa3-434a-a144-dfc36f784d9e",
|
|
"name": "Spawner (604)",
|
|
"location": [843, 3439, -20],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Banker", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Minter", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "f2b8fe59-6e8b-4db1-a20c-9663efe3b725",
|
|
"name": "Spawner (604)",
|
|
"location": [702, 3435, -20],
|
|
"map": "TerMur",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Tailor", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Weaver", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "59459615-255f-449a-b4ee-c6adf848c09a",
|
|
"name": "Spawner (604)",
|
|
"location": [727, 3425, -20],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Armorer", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Weaponsmith", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "fa9d60df-aa99-4934-bfab-35066f170481",
|
|
"name": "Spawner (604)",
|
|
"location": [715, 3446, -20],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Farmer", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Farmer", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "27644d88-d3d5-458f-902d-78b7b59eaf95",
|
|
"name": "Spawner (604)",
|
|
"location": [713, 3438, -20],
|
|
"map": "TerMur",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "TavernKeeper", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Waiter", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Barkeeper", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "2a0e5133-2658-44f4-95ca-bcc5d68ddaeb",
|
|
"name": "Spawner (604)",
|
|
"location": [794, 3446, -10],
|
|
"map": "TerMur",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Farmer", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Farmer", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "6d91f1ff-12e5-4d75-aa5a-033ff00b9a24",
|
|
"name": "Spawner (604)",
|
|
"location": [779, 3431, -10],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 5,
|
|
"entries": [
|
|
{ "name": "Jeweler", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "a1ba61c1-0fc5-46a4-b7de-1350606fb0fc",
|
|
"name": "Spawner (604)",
|
|
"location": [842, 3453, -18],
|
|
"map": "TerMur",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 0,
|
|
"walkingRange": 4,
|
|
"entries": [
|
|
{ "name": "TownCrier", "maxCount": 1, "probability": 100 }
|
|
]
|
|
}
|
|
]
|