## 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.
481 lines
13 KiB
JSON
481 lines
13 KiB
JSON
[
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "01f5635b-645f-43a3-b8e1-de505a9805f4",
|
|
"name": "Spawner (106)",
|
|
"location": [5269, 811, 7],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Dragon", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Drake", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "039faa13-d838-4ccf-8790-13500ac30d28",
|
|
"name": "Spawner (106)",
|
|
"location": [5141, 834, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Drake", "maxCount": 1, "probability": 100 },
|
|
{ "name": "ShadowWyrm", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "0f5e5a38-ef2b-4e49-8574-b5349c21dd06",
|
|
"name": "Spawner (106)",
|
|
"location": [5352, 934, -5],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "WaterElemental", "maxCount": 4, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "22700190-506d-4f55-be3b-4ecf63f097a1",
|
|
"name": "Spawner (106)",
|
|
"location": [5152, 869, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 25,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Drake", "maxCount": 1, "probability": 100 },
|
|
{ "name": "ShadowWyrm", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "2d985bfc-95b3-40be-8a3e-f1919da38b19",
|
|
"name": "Spawner (106)",
|
|
"location": [5209, 965, -40],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Dragon", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Drake", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "31627f88-cd2c-42f2-8aa2-df019970100f",
|
|
"name": "Spawner (106)",
|
|
"location": [5294, 842, 0],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "WaterElemental", "maxCount": 4, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "34426acb-8319-4eb6-8760-d0acb86670f9",
|
|
"name": "Spawner (106)",
|
|
"location": [5247, 956, -40],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Dragon", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Drake", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "45519d94-93ec-4b71-9fcd-0693de58f4e8",
|
|
"name": "Spawner (106)",
|
|
"location": [5229, 841, 1],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "TreasureChestLevel2", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TreasureChestLevel3", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "4a321325-9193-4861-a25e-c6f075636a07",
|
|
"name": "Spawner (106)",
|
|
"location": [5241, 945, -40],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "TreasureChestLevel3", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TreasureChestLevel4", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "4dea2c35-b11b-43cd-9916-6ee8d03105c7",
|
|
"name": "Spawner (106)",
|
|
"location": [5153, 842, 0],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 40,
|
|
"walkingRange": 40,
|
|
"entries": [
|
|
{ "name": "TreasureChestLevel4", "maxCount": 4, "probability": 100 },
|
|
{ "name": "TreasureChestLevel3", "maxCount": 4, "probability": 100 },
|
|
{ "name": "TreasureChestLevel2", "maxCount": 4, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "58412982-34fe-41e4-9c85-cd0d5691d62c",
|
|
"name": "Spawner (106)",
|
|
"location": [5143, 986, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "TreasureChestLevel3", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TreasureChestLevel4", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "68815cec-f528-4033-be41-04c6d75415e1",
|
|
"name": "Spawner (106)",
|
|
"location": [5161, 940, 0],
|
|
"map": "Trammel",
|
|
"count": 6,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 20,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "GiantSerpent", "maxCount": 6, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "699dfc8f-e891-4978-9c73-a74cb1762849",
|
|
"name": "Spawner (106)",
|
|
"location": [5185, 1006, 0],
|
|
"map": "Trammel",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 20,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "AncientWyrm", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "6b05c0d4-7c45-4d29-9f15-adbb0b12686f",
|
|
"name": "Spawner (106)",
|
|
"location": [5141, 915, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 25,
|
|
"walkingRange": 25,
|
|
"entries": [
|
|
{ "name": "TreasureChestLevel2", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TreasureChestLevel3", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "73ef3d35-2c97-40c0-8c9d-f949bedf4bf0",
|
|
"name": "Spawner (106)",
|
|
"location": [5169, 836, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Daemon", "maxCount": 2, "probability": 100 },
|
|
{ "name": "EvilMage", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "89faa9ac-5c83-4145-a239-0f1bbc3088a6",
|
|
"name": "Spawner (106)",
|
|
"location": [5226, 826, 0],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Dragon", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Drake", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "8a097ab1-3372-4f64-bb50-b18470eed97f",
|
|
"name": "Spawner (106)",
|
|
"location": [5250, 869, 0],
|
|
"map": "Trammel",
|
|
"count": 6,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "GiantSerpent", "maxCount": 6, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "95541af6-6526-41ad-8329-a9819a3a1f8d",
|
|
"name": "Spawner (106)",
|
|
"location": [5284, 926, -40],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Dragon", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Drake", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "9f0b2731-3658-43bd-9fe4-3bca1a7c5212",
|
|
"name": "Spawner (106)",
|
|
"location": [5317, 980, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 7,
|
|
"walkingRange": 7,
|
|
"entries": [
|
|
{ "name": "TreasureChestLevel3", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TreasureChestLevel4", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "b2eb4c30-1f29-4ef5-b80a-851c68af76f9",
|
|
"name": "Spawner (106)",
|
|
"location": [5157, 997, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 20,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Wyvern", "maxCount": 3, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 3, "probability": 100 },
|
|
{ "name": "GiantSerpent", "maxCount": 3, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "b705ea99-1d95-42ff-8287-fb6588d0ed24",
|
|
"name": "Spawner (106)",
|
|
"location": [5149, 907, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 15,
|
|
"walkingRange": 20,
|
|
"entries": [
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Dragon", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Drake", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "cde84676-d0c3-44e7-8be3-9335dc9c5b1b",
|
|
"name": "Spawner (106)",
|
|
"location": [5203, 787, 5],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 12,
|
|
"walkingRange": 25,
|
|
"entries": [
|
|
{ "name": "WaterElemental", "maxCount": 4, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "d6da5d02-55ae-4e14-8aec-e02b2e7cf188",
|
|
"name": "Spawner (106)",
|
|
"location": [5307, 815, 0],
|
|
"map": "Trammel",
|
|
"count": 2,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Drake", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "da94b96f-7cbc-4016-91b5-6ec3df649e23",
|
|
"name": "Spawner (106)",
|
|
"location": [5214, 917, -40],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Dragon", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Drake", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "db505150-0a76-44ac-8b27-841ef6b87321",
|
|
"name": "Spawner (106)",
|
|
"location": [5183, 1007, 0],
|
|
"map": "Trammel",
|
|
"count": 3,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "TreasureChestLevel3", "maxCount": 1, "probability": 100 },
|
|
{ "name": "TreasureChestLevel4", "maxCount": 2, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "def6615c-5bbd-4b7c-ab81-4dc4327c97d8",
|
|
"name": "Spawner (106)",
|
|
"location": [5205, 778, 0],
|
|
"map": "Trammel",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 7,
|
|
"walkingRange": 7,
|
|
"entries": [
|
|
{ "name": "TreasureChestLevel3", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "fb1e3f19-4276-4b1f-b87f-0b4e38ebd70b",
|
|
"name": "Spawner (106)",
|
|
"location": [5141, 968, 0],
|
|
"map": "Trammel",
|
|
"count": 1,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 10,
|
|
"walkingRange": 10,
|
|
"entries": [
|
|
{ "name": "FireElemental", "maxCount": 1, "probability": 100 }
|
|
]
|
|
},
|
|
{
|
|
"$type": "Spawner",
|
|
"guid": "fce8877c-f374-4e24-9d3f-65205427600e",
|
|
"name": "Spawner (106)",
|
|
"location": [5280, 955, -40],
|
|
"map": "Trammel",
|
|
"count": 4,
|
|
"minDelay": "00:05:00",
|
|
"maxDelay": "00:10:00",
|
|
"team": 0,
|
|
"homeRange": 30,
|
|
"walkingRange": 50,
|
|
"entries": [
|
|
{ "name": "Dragon", "maxCount": 1, "probability": 100 },
|
|
{ "name": "Drake", "maxCount": 2, "probability": 100 },
|
|
{ "name": "Wyvern", "maxCount": 1, "probability": 100 }
|
|
]
|
|
}
|
|
]
|