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

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 }
]
}
]