From d4e3e3c9ef874ba384b3c2d7532c92fb97fc5ec3 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:14:38 -0700 Subject: [PATCH] docs(spawners): add DynamicJson removal spec + implementation plan Design + step-by-step plan to delete DynamicJson and migrate spawners to typed System.Text.Json (discovery-based polymorphism, $type discriminator). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-25-spawner-stj-migration.md | 1773 +++++++++++++++++ ...2026-06-25-spawner-stj-migration-design.md | 248 +++ 2 files changed, 2021 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-spawner-stj-migration.md create mode 100644 docs/superpowers/specs/2026-06-25-spawner-stj-migration-design.md diff --git a/docs/superpowers/plans/2026-06-25-spawner-stj-migration.md b/docs/superpowers/plans/2026-06-25-spawner-stj-migration.md new file mode 100644 index 000000000..952e5652d --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-spawner-stj-migration.md @@ -0,0 +1,1773 @@ +# Spawner STJ Migration (Remove DynamicJson) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete `DynamicJson` and make spawners directly System.Text.Json-(de)serializable via a discovery-based polymorphic serializer, with the data files migrated to the `$type` discriminator. + +**Architecture:** Each concrete spawner stays the STJ target (no DTOs). JSON binds to inert, hand-written **shadow properties** (backed by transient `_json*` fields; getters read live state for export, setters stash raw values for import). All imperative wiring (`InitSpawn`, `AddEntry`, legacy `homeRange`→`spawnBounds`, region lookup) runs in a single `OnAfterJsonDeserialize()` virtual hook fired by the resolver's `OnDeserialized`. A `SpawnerJsonSerializer` (mirroring `RegionJsonSerializer`) auto-discovers `[JsonDiscoverableType]`-marked `BaseSpawner` subclasses at the `Configure` phase, wires STJ polymorphism, prunes non-JSON engine properties, and validates loudly. + +**Tech Stack:** .NET 10, System.Text.Json, xUnit, ModernUO source-generated serialization. + +## Global Constraints + +- **Single-threaded game loop.** No `lock`/`volatile`/`Concurrent*`/`Task.Run`/`new Thread()` in game code. (The one-time migration tool is a standalone offline utility and may use ordinary file I/O.) +- **Do not modify `Projects/Server/` beyond:** adding `JsonDiscoverableTypeAttribute` and **deleting** `DynamicJson.cs`. Everything else lives in `Projects/UOContent/`. +- **Do not touch binary world-save serialization** (`[SerializationGenerator]`, `[SerializableField]`, `Deserialize(reader, version)`, `MigrateFrom`). The JSON path is independent. +- **Braces on all control flow.** `_camelCase` private fields, `PascalCase` public members. +- **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(X))`. +- **Sparse export must match today's `ToJson` output exactly** (which fields are present/omitted), achieved via nullable shadow getters + `JsonIgnoreCondition.WhenWritingNull` (already the default in `JsonConfig.DefaultOptions`). +- **Discriminator:** STJ-default `$type`; discriminator value = `type.Name` unless overridden. Existing values (`"Spawner"`, `"RegionSpawner"`, `"ProximitySpawner"`) are unchanged; only the key changes from `"type"`. +- **Tests:** xUnit. World-dependent tests use `[Collection("Sequential UOContent Tests")]`. `SpawnerJsonSerializer.Configure()` must be called once in `TestServerInitializer` (production calls it via `AssemblyHandler.Invoke("Configure")`). +- **Build:** `dotnet build ModernUO.sln` from repo root. **Test:** `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj`. + +> **Note (refines spec D1):** the spec proposed `ShouldSerialize` modifiers for sparse output. During planning we found nullable shadow getters + `WhenWritingNull` produce identical output with less machinery and no resolver property-iteration for serialization. This plan uses that. All other spec decisions (D2–D5) stand. + +--- + +## File Structure + +**Create:** +- `Projects/Server/Json/JsonDiscoverableTypeAttribute.cs` — reusable opt-in marker. +- `Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs` — discovery, options, resolver. +- `Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs` — BaseSpawner shadow props + `OnAfterJsonDeserialize` (partial; keeps JSON concerns out of the main file). +- `Projects/UOContent/Engines/Spawners/Spawner.Json.cs` +- `Projects/UOContent/Engines/Spawners/RegionSpawner.Json.cs` +- `Projects/UOContent/Engines/Spawners/ProximitySpawner.Json.cs` +- `tools/spawner-json-migrate/` — one-time offline conversion utility (throwaway; not part of the server build). +- Tests under `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/`. + +**Modify:** +- `Projects/UOContent/Engines/Spawners/BaseSpawner.cs` — remove `(DynamicJson, options)` ctor and `ToJson`; add `[JsonDiscoverableType]` is **not** placed here (abstract). Add `[JsonConstructor]` to the parameterless ctor. +- `Projects/UOContent/Engines/Spawners/Spawner.cs`, `RegionSpawner.cs`, `ProximitySpawner.cs` — remove `(DynamicJson,…)` ctor + `ToJson`; add `[JsonDiscoverableType]` + `[JsonConstructor]`. +- `Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs`, `ImportSpawnersCommand.cs` — rewire to typed (de)serialization. +- `Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs` — call `SpawnerJsonSerializer.Configure()`. +- `Distribution/Data/Spawns/**/*.json` — one-time migrated output (Task 8). + +**Delete:** +- `Projects/Server/Json/DynamicJson.cs` (Task 9). + +--- + +## Task 1: Reusable opt-in marker attribute + +**Files:** +- Create: `Projects/Server/Json/JsonDiscoverableTypeAttribute.cs` +- Test: `Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs` + +**Interfaces:** +- Produces: `Server.Json.JsonDiscoverableTypeAttribute(string discriminator = null)` with `string Discriminator { get; }`. + +- [ ] **Step 1: Write the failing test** + +```csharp +using Server.Json; +using Xunit; + +namespace Server.Tests.Json; + +public class JsonDiscoverableTypeAttributeTests +{ + [JsonDiscoverableType] + private sealed class DefaultName { } + + [JsonDiscoverableType("custom")] + private sealed class Overridden { } + + [Fact] + public void DefaultDiscriminator_IsNull() + { + var attr = (JsonDiscoverableTypeAttribute)System.Attribute.GetCustomAttribute( + typeof(DefaultName), typeof(JsonDiscoverableTypeAttribute), false); + Assert.NotNull(attr); + Assert.Null(attr.Discriminator); + } + + [Fact] + public void OverrideDiscriminator_IsReturned() + { + var attr = (JsonDiscoverableTypeAttribute)System.Attribute.GetCustomAttribute( + typeof(Overridden), typeof(JsonDiscoverableTypeAttribute), false); + Assert.Equal("custom", attr.Discriminator); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test Projects/Server.Tests/Server.Tests.csproj --filter JsonDiscoverableTypeAttributeTests` +Expected: FAIL — `JsonDiscoverableTypeAttribute` does not exist (compile error). + +- [ ] **Step 3: Write minimal implementation** + +```csharp +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: JsonDiscoverableTypeAttribute.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; + +namespace Server.Json; + +/// +/// Marks a concrete class as a discoverable polymorphic JSON derived type. A consumer +/// (e.g. SpawnerJsonSerializer) scans assemblies for marked subclasses of a chosen +/// base and registers them for System.Text.Json polymorphism. Optionally overrides the +/// $type discriminator value (defaults to the type's ). +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class JsonDiscoverableTypeAttribute : Attribute +{ + public JsonDiscoverableTypeAttribute(string discriminator = null) => Discriminator = discriminator; + + public string Discriminator { get; } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test Projects/Server.Tests/Server.Tests.csproj --filter JsonDiscoverableTypeAttributeTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Projects/Server/Json/JsonDiscoverableTypeAttribute.cs \ + Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs +git commit -m "feat(json): add reusable JsonDiscoverableType opt-in marker attribute" +``` + +--- + +## Task 2: SpawnerJsonSerializer + BaseSpawner/Spawner JSON binding (core round-trip) + +This is the foundational slice: the serializer plus the first end-to-end round-trip. It validates discovery, polymorphism, property pruning, the shadow-property pattern, and `OnAfterJsonDeserialize`. + +**Files:** +- Create: `Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs` +- Create: `Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs` +- Create: `Projects/UOContent/Engines/Spawners/Spawner.Json.cs` +- Modify: `Projects/UOContent/Engines/Spawners/Spawner.cs` (add `[JsonDiscoverableType]` + `[JsonConstructor]`; remove `(DynamicJson,…)` ctor + `ToJson` — see Task 9 if you prefer to defer deletion, but doing it here keeps the file compiling without `DynamicJson` once it is gone; for now leave the old members in place and remove in Task 9). **In this task, only ADD the attributes and the Json partial; do not delete the old `DynamicJson` members yet** so the project keeps building. +- Modify: `Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs` +- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerRoundTripTests.cs` + +**Interfaces:** +- Produces: + - `Server.Engines.Spawners.SpawnerJsonSerializer` with `static void Configure()`, `static JsonSerializerOptions Options { get; }`. + - `BaseSpawner.OnAfterJsonDeserialize()` — `protected internal virtual void`. + - BaseSpawner transient fields consumed by derived hooks: `_jsonLocation` (`Point3D`), `_jsonMap` (`Map`). +- Consumes: `Server.Json.JsonDiscoverableTypeAttribute` (Task 1); `Server.Json.JsonConfig`. + +- [ ] **Step 1: Write the failing test** + +```csharp +using System; +using System.Collections.Generic; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class SpawnerRoundTripTests +{ + private static Map Map => Map.Felucca; + + [Fact] + public void Spawner_RoundTrips_TypeAndCoreFields() + { + var spawner = new Spawner(2, TimeSpan.FromMinutes(3), TimeSpan.FromMinutes(7), 0, + new Rectangle3D(100, 100, 0, 5, 5, 0), "Fisherman"); + spawner.MoveToWorld(new Point3D(105, 105, 0), Map); + + var json = JsonSerializer.Serialize>( + new List { spawner }, SpawnerJsonSerializer.Options); + + Assert.Contains("\"$type\": \"Spawner\"", json); + Assert.Contains("\"count\": 2", json); + + var roundTripped = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); + var s = Assert.IsType(Assert.Single(roundTripped)); + Assert.Equal(2, s.Count); + Assert.Equal(TimeSpan.FromMinutes(3), s.MinDelay); + Assert.Equal(TimeSpan.FromMinutes(7), s.MaxDelay); + Assert.Equal(new Rectangle3D(100, 100, 0, 5, 5, 0), s.SpawnBounds); + Assert.Single(s.Entries); + Assert.Equal("Fisherman", s.Entries[0].SpawnedName); + + s.Delete(); + spawner.Delete(); + } + + [Fact] + public void Spawner_OmitsDomainDefaults() + { + // Default delays (5/10 min), team 0, default maxSpawnAttempts → omitted. + var spawner = new Spawner("Fisherman"); + spawner.MoveToWorld(new Point3D(110, 110, 0), Map); + + var json = JsonSerializer.Serialize>( + new List { spawner }, SpawnerJsonSerializer.Options); + + Assert.DoesNotContain("minDelay", json); + Assert.DoesNotContain("maxDelay", json); + Assert.DoesNotContain("\"team\"", json); + Assert.DoesNotContain("maxSpawnAttempts", json); + + spawner.Delete(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter SpawnerRoundTripTests` +Expected: FAIL — `SpawnerJsonSerializer` does not exist; `Spawner` not discoverable. + +- [ ] **Step 3a: Create the serializer** + +`Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs`: + +```csharp +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SpawnerJsonSerializer.cs * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Server.Json; +using Server.Logging; + +namespace Server.Engines.Spawners; + +public static class SpawnerJsonSerializer +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(SpawnerJsonSerializer)); + + private static JsonDerivedType[] _derivedTypes = Array.Empty(); + private static JsonSerializerOptions _options; + + /// + /// Invoked automatically during the Configure bootstrap phase + /// (AssemblyHandler.Invoke("Configure")). Discovers every concrete BaseSpawner subclass + /// marked with [JsonDiscoverableType] and registers it for STJ polymorphism. + /// + public static void Configure() + { + var discovered = new List(); + var byDiscriminator = new Dictionary(); + + foreach (var asm in AssemblyHandler.Assemblies) + { + Collect(AssemblyHandler.GetTypeCache(asm).Types, discovered, byDiscriminator); + } + + Collect(AssemblyHandler.GetTypeCache(Core.Assembly).Types, discovered, byDiscriminator); + + _derivedTypes = discovered.ToArray(); + _options = null; // force rebuild with the discovered types + + logger.Information("Discovered {Count} spawner JSON type(s)", _derivedTypes.Length); + } + + private static void Collect(Type[] types, List discovered, Dictionary byDiscriminator) + { + for (var i = 0; i < types.Length; i++) + { + var type = types[i]; + if (type.IsAbstract || !type.IsAssignableTo(typeof(BaseSpawner))) + { + continue; + } + + var attr = (JsonDiscoverableTypeAttribute)Attribute.GetCustomAttribute( + type, typeof(JsonDiscoverableTypeAttribute), false); + if (attr == null) + { + continue; + } + + if (!IsJsonConstructible(type)) + { + throw new Exception( + $"Spawner type '{type.FullName}' is marked [JsonDiscoverableType] but System.Text.Json cannot construct it. " + + "Add a public parameterless constructor marked [JsonConstructor]." + ); + } + + var discriminator = attr.Discriminator ?? type.Name; + if (byDiscriminator.TryGetValue(discriminator, out var existing)) + { + throw new Exception( + $"Spawner JSON discriminator '{discriminator}' is claimed by both '{existing.FullName}' and " + + $"'{type.FullName}'. Set an explicit discriminator via [JsonDiscoverableType(\"...\")] on one." + ); + } + + byDiscriminator[discriminator] = type; + discovered.Add(new JsonDerivedType(type, discriminator)); + } + } + + private static bool IsJsonConstructible(Type type) + { + foreach (var ctor in type.GetConstructors()) + { + if (ctor.GetParameters().Length == 0) + { + return true; + } + + if (Attribute.IsDefined(ctor, typeof(JsonConstructorAttribute))) + { + return true; + } + } + + return false; + } + + public static JsonSerializerOptions Options => + _options ??= new JsonSerializerOptions(JsonConfig.GetOptions(new TextDefinitionConverterFactory())) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + AddPolymorphism, + PruneToJsonProperties, + AddOnDeserialized + } + } + }; + + private static void AddPolymorphism(JsonTypeInfo typeInfo) + { + if (typeInfo.Type != typeof(BaseSpawner)) + { + return; + } + + typeInfo.PolymorphismOptions = new JsonPolymorphismOptions(); + for (var i = 0; i < _derivedTypes.Length; i++) + { + typeInfo.PolymorphismOptions.DerivedTypes.Add(_derivedTypes[i]); + } + } + + // Spawners are Items with many public engine properties STJ would otherwise (de)serialize. + // Keep ONLY properties explicitly annotated with [JsonPropertyName] (our shadow properties). + private static void PruneToJsonProperties(JsonTypeInfo typeInfo) + { + if (!typeInfo.Type.IsAssignableTo(typeof(BaseSpawner))) + { + return; + } + + for (var i = typeInfo.Properties.Count - 1; i >= 0; i--) + { + var provider = typeInfo.Properties[i].AttributeProvider; + var keep = provider?.IsDefined(typeof(JsonPropertyNameAttribute), true) ?? false; + if (!keep) + { + typeInfo.Properties.RemoveAt(i); + } + } + } + + private static void AddOnDeserialized(JsonTypeInfo typeInfo) + { + if (!typeInfo.Type.IsAssignableTo(typeof(BaseSpawner))) + { + return; + } + + typeInfo.OnDeserialized = static o => ((BaseSpawner)o).OnAfterJsonDeserialize(); + } +} +``` + +- [ ] **Step 3b: Add the BaseSpawner JSON partial** + +`Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs`: + +```csharp +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BaseSpawner.Json.cs * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Server.Engines.Spawners; + +public abstract partial class BaseSpawner +{ + // Transient import-only state (NOT [SerializableField]; never part of the binary save). + private Guid _jsonGuid; + private bool _jsonHasGuid; + private int _jsonCount; + private TimeSpan _jsonMinDelay = DefaultMinDelay; + private TimeSpan _jsonMaxDelay = DefaultMaxDelay; + private int _jsonTeam; + private int _jsonWalkingRange = -1; + private int _jsonHomeRange = -1; + private bool _jsonSpawnLocationIsHome; + private SpawnPositionMode _jsonSpawnPositionMode; + private int _jsonMaxSpawnAttempts = DefaultMaxSpawnAttempts; + private List _jsonEntries; + private protected Point3D _jsonLocation; + private protected Map _jsonMap; + + // --- Always-written fields --- + + [JsonInclude] + [JsonPropertyName("guid")] + public Guid JsonGuid + { + get => _guid; + set + { + _jsonGuid = value; + _jsonHasGuid = true; + } + } + + [JsonInclude] + [JsonPropertyName("location")] + public Point3D JsonLocation + { + get => Location; + set => _jsonLocation = value; + } + + [JsonInclude] + [JsonPropertyName("map")] + public Map JsonMap + { + get => Map; + set => _jsonMap = value; + } + + [JsonInclude] + [JsonPropertyName("count")] + public int JsonCount + { + get => Count; + set => _jsonCount = value; + } + + [JsonInclude] + [JsonPropertyName("entries")] + public List JsonEntries + { + get => Entries; + set => _jsonEntries = value; + } + + // --- Conditionally-written fields (null getter => omitted under WhenWritingNull) --- + + [JsonInclude] + [JsonPropertyName("name")] + public string JsonName + { + get => string.IsNullOrEmpty(Name) ? null : Name; + set => Name = value; + } + + [JsonInclude] + [JsonPropertyName("minDelay")] + public TimeSpan? JsonMinDelay + { + get => _minDelay == DefaultMinDelay ? null : _minDelay; + set => _jsonMinDelay = value ?? DefaultMinDelay; + } + + [JsonInclude] + [JsonPropertyName("maxDelay")] + public TimeSpan? JsonMaxDelay + { + get => _maxDelay == DefaultMaxDelay ? null : _maxDelay; + set => _jsonMaxDelay = value ?? DefaultMaxDelay; + } + + [JsonInclude] + [JsonPropertyName("team")] + public int? JsonTeam + { + get => _team == 0 ? null : _team; + set => _jsonTeam = value ?? 0; + } + + // Mirrors today's ToJson exactly: written when _walkingRange != 0, emitting the WalkingRange property. + [JsonInclude] + [JsonPropertyName("walkingRange")] + public int? JsonWalkingRange + { + get => _walkingRange != 0 ? WalkingRange : null; + set => _jsonWalkingRange = value ?? -1; + } + + [JsonInclude] + [JsonPropertyName("spawnLocationIsHome")] + public bool? JsonSpawnLocationIsHome + { + get => _spawnLocationIsHome ? true : null; + set => _jsonSpawnLocationIsHome = value ?? false; + } + + [JsonInclude] + [JsonPropertyName("spawnPositionMode")] + public SpawnPositionMode? JsonSpawnPositionMode + { + get => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned + ? _spawnPositionMode + : null; + set => _jsonSpawnPositionMode = value ?? SpawnPositionMode.Automatic; + } + + [JsonInclude] + [JsonPropertyName("maxSpawnAttempts")] + public int? JsonMaxSpawnAttempts + { + get => _maxSpawnAttempts != DefaultMaxSpawnAttempts ? _maxSpawnAttempts : null; + set => _jsonMaxSpawnAttempts = value ?? DefaultMaxSpawnAttempts; + } + + // Legacy read-only: present in old files; converted in OnAfterJsonDeserialize. Never written + // (getter always null) — modern files carry spawnBounds instead. + [JsonInclude] + [JsonPropertyName("homeRange")] + public int? JsonHomeRange + { + get => null; + set => _jsonHomeRange = value ?? -1; + } + + /// + /// Applies the deserialized JSON state to this live spawner. Fired by the resolver's + /// OnDeserialized after all shadow properties are set. Overrides MUST call base first. + /// This replaces the former (DynamicJson, options) constructor body. + /// + protected internal virtual void OnAfterJsonDeserialize() + { + if (_jsonHasGuid) + { + _guid = _jsonGuid; + } + + // Legacy homeRange -> spawnBounds (Map not available yet; use the deserialized location). + if (_jsonHomeRange >= 0) + { + int z; + int depth; + if (_jsonHomeRange == 0) + { + z = _jsonLocation.Z; + depth = 0; + } + else + { + z = -128; + depth = 256; + } + + SpawnBounds = new Rectangle3D( + _jsonLocation.X - _jsonHomeRange, + _jsonLocation.Y - _jsonHomeRange, + z, + _jsonHomeRange * 2 + 1, + _jsonHomeRange * 2 + 1, + depth + ); + } + + InitSpawn(_jsonCount, _jsonMinDelay, _jsonMaxDelay, _jsonTeam, SpawnBounds); + + _walkingRange = _jsonWalkingRange; + _spawnLocationIsHome = _jsonSpawnLocationIsHome; + _spawnPositionMode = _jsonSpawnPositionMode; + _maxSpawnAttempts = _jsonMaxSpawnAttempts; + + if (_jsonEntries != null) + { + for (var i = 0; i < _jsonEntries.Count; i++) + { + var entry = _jsonEntries[i]; + AddEntry(entry.SpawnedName, entry.SpawnedProbability, entry.SpawnedMaxCount, false, entry.Properties, entry.Parameters); + } + } + } +} +``` + +> The `private` modifiers `_guid`, `_minDelay`, `_maxDelay`, `_team`, `_walkingRange`, `_spawnLocationIsHome`, `_spawnPositionMode`, `_maxSpawnAttempts`, `Entries`, `WalkingRange`, `Name`, `Count`, `Location`, `Map`, `SpawnBounds`, `InitSpawn`, `AddEntry`, `DefaultMinDelay`, `DefaultMaxDelay`, `DefaultMaxSpawnAttempts` are all defined on `BaseSpawner` in the same assembly; the partial sees them. `DefaultMaxSpawnAttempts` and `DefaultMinDelay`/`DefaultMaxDelay` are existing `private`/`private const` members — accessible to the partial. + +- [ ] **Step 3c: Add the Spawner JSON partial** + +`Projects/UOContent/Engines/Spawners/Spawner.Json.cs`: + +```csharp +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: Spawner.Json.cs * + *************************************************************************/ + +using System.Text.Json.Serialization; + +namespace Server.Engines.Spawners; + +public partial class Spawner +{ + private Rectangle3D _jsonSpawnBounds; + + [JsonInclude] + [JsonPropertyName("spawnBounds")] + public Rectangle3D? JsonSpawnBounds + { + get => _spawnBounds == default ? null : _spawnBounds; + set => _jsonSpawnBounds = value ?? default; + } + + protected internal override void OnAfterJsonDeserialize() + { + base.OnAfterJsonDeserialize(); + + if (_jsonSpawnBounds != default) + { + SpawnBounds = _jsonSpawnBounds; + } + } +} +``` + +- [ ] **Step 3d: Mark `Spawner` discoverable + constructible** + +In `Projects/UOContent/Engines/Spawners/Spawner.cs`, add `using Server.Json;` and the class attribute, and `[JsonConstructor]` on the parameterless ctor: + +```csharp +[SerializationGenerator(1)] +[JsonDiscoverableType] +public partial class Spawner : BaseSpawner +``` + +```csharp + [Constructible(AccessLevel.Developer)] + [System.Text.Json.Serialization.JsonConstructor] + public Spawner() + { + } +``` + +> Leave the existing `(DynamicJson, options)` ctor and `ToJson` override in place for now (Task 9 deletes them). The new shadow properties live in the `.Json.cs` partial and do not collide. + +- [ ] **Step 3e: Wire discovery into the test bootstrap** + +In `Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs`, add the call alongside the other curated `Configure()` calls (after `World.Load()` is fine; discovery only reads loaded assemblies): + +```csharp + DecayScheduler.Configure(); + Server.Engines.Spawners.SpawnerJsonSerializer.Configure(); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter SpawnerRoundTripTests` +Expected: PASS (2 tests). If `$type` is not emitted, confirm the list is serialized as `List` (the static type must be the polymorphic base). + +- [ ] **Step 5: Commit** + +```bash +git add Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs \ + Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs \ + Projects/UOContent/Engines/Spawners/Spawner.Json.cs \ + Projects/UOContent/Engines/Spawners/Spawner.cs \ + Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs \ + Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerRoundTripTests.cs +git commit -m "feat(spawners): add SpawnerJsonSerializer + typed JSON binding for Spawner" +``` + +--- + +## Task 3: RegionSpawner JSON binding + +**Files:** +- Create: `Projects/UOContent/Engines/Spawners/RegionSpawner.Json.cs` +- Modify: `Projects/UOContent/Engines/Spawners/RegionSpawner.cs` (attributes + `[JsonConstructor]`) +- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/RegionSpawnerRoundTripTests.cs` + +**Interfaces:** +- Consumes: `BaseSpawner.OnAfterJsonDeserialize`, `_jsonMap` (Task 2). + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Collections.Generic; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Server.Regions; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class RegionSpawnerRoundTripTests +{ + [Fact] + public void RegionSpawner_RoundTrips_RegionByName() + { + // Pick any registered region on Felucca for the round-trip. + var region = Region.Find(new Point3D(1416, 1683, 0), Map.Felucca) as BaseRegion; + Assert.NotNull(region); + + var spawner = new RegionSpawner("Fisherman") { SpawnRegion = region }; + spawner.MoveToWorld(new Point3D(1416, 1683, 0), Map.Felucca); + + var json = JsonSerializer.Serialize>( + new List { spawner }, SpawnerJsonSerializer.Options); + Assert.Contains("\"$type\": \"RegionSpawner\"", json); + Assert.Contains(region.Name, json); + + var rt = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); + var s = Assert.IsType(Assert.Single(rt)); + Assert.Equal(region.Name, s.SpawnRegion?.Name); + + s.Delete(); + spawner.Delete(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter RegionSpawnerRoundTripTests` +Expected: FAIL — `RegionSpawner` not discoverable (no `[JsonDiscoverableType]`), `region` not bound. + +- [ ] **Step 3a: Add the RegionSpawner JSON partial** + +`Projects/UOContent/Engines/Spawners/RegionSpawner.Json.cs`: + +```csharp +/************************************************************************* + * ModernUO * + * File: RegionSpawner.Json.cs * + *************************************************************************/ + +using System.Text.Json.Serialization; +using Server.Regions; + +namespace Server.Engines.Spawners; + +public partial class RegionSpawner +{ + private string _jsonRegion; + + [JsonInclude] + [JsonPropertyName("region")] + public string JsonRegion + { + get => SpawnRegion?.Name; + set => _jsonRegion = value; + } + + protected internal override void OnAfterJsonDeserialize() + { + base.OnAfterJsonDeserialize(); + + _spawnRegion = Region.Find(_jsonRegion, _jsonMap) as BaseRegion; + _spawnRegion?.InitRectangles(); + SpawnRegionName = _spawnRegion?.Name; + } +} +``` + +- [ ] **Step 3b: Mark `RegionSpawner` discoverable + constructible** + +In `Projects/UOContent/Engines/Spawners/RegionSpawner.cs`, add `using Server.Json;`, the class attribute, and `[JsonConstructor]` on the parameterless ctor: + +```csharp +[SerializationGenerator(0)] +[JsonDiscoverableType] +public partial class RegionSpawner : Spawner +``` + +```csharp + [Constructible(AccessLevel.Developer)] + [System.Text.Json.Serialization.JsonConstructor] + public RegionSpawner() + { + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter RegionSpawnerRoundTripTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Projects/UOContent/Engines/Spawners/RegionSpawner.Json.cs \ + Projects/UOContent/Engines/Spawners/RegionSpawner.cs \ + Projects/UOContent.Tests/Tests/Engines/Spawners/Json/RegionSpawnerRoundTripTests.cs +git commit -m "feat(spawners): add typed JSON binding for RegionSpawner" +``` + +--- + +## Task 4: ProximitySpawner JSON binding + +**Files:** +- Create: `Projects/UOContent/Engines/Spawners/ProximitySpawner.Json.cs` +- Modify: `Projects/UOContent/Engines/Spawners/ProximitySpawner.cs` +- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ProximitySpawnerRoundTripTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Collections.Generic; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class ProximitySpawnerRoundTripTests +{ + [Fact] + public void ProximitySpawner_RoundTrips_ProximityFields() + { + var spawner = new ProximitySpawner("Fisherman") { TriggerRange = 4, InstantFlag = true }; + spawner.SpawnMessage = 500000; + spawner.MoveToWorld(new Point3D(120, 120, 0), Map.Felucca); + + var json = JsonSerializer.Serialize>( + new List { spawner }, SpawnerJsonSerializer.Options); + Assert.Contains("\"$type\": \"ProximitySpawner\"", json); + Assert.Contains("\"triggerRange\": 4", json); + Assert.Contains("\"instant\": true", json); + + var rt = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); + var s = Assert.IsType(Assert.Single(rt)); + Assert.Equal(4, s.TriggerRange); + Assert.True(s.InstantFlag); + Assert.Equal(500000, s.SpawnMessage.Number); + + s.Delete(); + spawner.Delete(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter ProximitySpawnerRoundTripTests` +Expected: FAIL — `ProximitySpawner` not discoverable. + +- [ ] **Step 3a: Add the ProximitySpawner JSON partial** + +`Projects/UOContent/Engines/Spawners/ProximitySpawner.Json.cs`: + +```csharp +/************************************************************************* + * ModernUO * + * File: ProximitySpawner.Json.cs * + *************************************************************************/ + +using System.Text.Json.Serialization; + +namespace Server.Engines.Spawners; + +public partial class ProximitySpawner +{ + private int _jsonTriggerRange; + private TextDefinition _jsonSpawnMessage; + private bool _jsonInstant; + + [JsonInclude] + [JsonPropertyName("triggerRange")] + public int JsonTriggerRange + { + get => TriggerRange; + set => _jsonTriggerRange = value; + } + + [JsonInclude] + [JsonPropertyName("spawnMessage")] + public TextDefinition JsonSpawnMessage + { + get => SpawnMessage; + set => _jsonSpawnMessage = value; + } + + [JsonInclude] + [JsonPropertyName("instant")] + public bool JsonInstant + { + get => InstantFlag; + set => _jsonInstant = value; + } + + protected internal override void OnAfterJsonDeserialize() + { + base.OnAfterJsonDeserialize(); + + TriggerRange = _jsonTriggerRange; + SpawnMessage = _jsonSpawnMessage; + InstantFlag = _jsonInstant; + } +} +``` + +- [ ] **Step 3b: Mark `ProximitySpawner` discoverable + constructible** + +In `Projects/UOContent/Engines/Spawners/ProximitySpawner.cs`, add `using Server.Json;`, the class attribute, and `[JsonConstructor]` on the parameterless ctor: + +```csharp +[SerializationGenerator(0)] +[JsonDiscoverableType] +public partial class ProximitySpawner : Spawner +``` + +```csharp + [Constructible(AccessLevel.Developer)] + [System.Text.Json.Serialization.JsonConstructor] + public ProximitySpawner() + { + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter ProximitySpawnerRoundTripTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Projects/UOContent/Engines/Spawners/ProximitySpawner.Json.cs \ + Projects/UOContent/Engines/Spawners/ProximitySpawner.cs \ + Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ProximitySpawnerRoundTripTests.cs +git commit -m "feat(spawners): add typed JSON binding for ProximitySpawner" +``` + +--- + +## Task 5: Legacy `homeRange` read path + +**Files:** +- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/LegacyHomeRangeTests.cs` + +The implementation already exists in `BaseSpawner.OnAfterJsonDeserialize` (Task 2). This task adds the regression test that locks the legacy contract (D3). + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Collections.Generic; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class LegacyHomeRangeTests +{ + [Fact] + public void HomeRange_NoSpawnBounds_ProducesCenteredBounds() + { + const string legacy = """ + [ + { + "$type": "Spawner", + "guid": "3df0543a-373c-4673-a98b-8191686f4ab3", + "location": [100, 200, 5], + "map": "Felucca", + "count": 1, + "homeRange": 3, + "entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ] + } + ] + """; + + var rt = JsonSerializer.Deserialize>(legacy, SpawnerJsonSerializer.Options); + var s = Assert.IsType(Assert.Single(rt)); + + // homeRange 3 -> Rectangle3D(100-3, 200-3, -128, 7, 7, 256) + Assert.Equal(new Rectangle3D(97, 197, -128, 7, 7, 256), s.SpawnBounds); + + s.Delete(); + } + + [Fact] + public void HomeRangeZero_ProducesSingleTileBounds() + { + const string legacy = """ + [ + { + "$type": "Spawner", + "location": [100, 200, 5], + "map": "Felucca", + "count": 1, + "homeRange": 0, + "entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ] + } + ] + """; + + var rt = JsonSerializer.Deserialize>(legacy, SpawnerJsonSerializer.Options); + var s = Assert.IsType(Assert.Single(rt)); + + // homeRange 0 -> Rectangle3D(100, 200, 5, 1, 1, 0) + Assert.Equal(new Rectangle3D(100, 200, 5, 1, 1, 0), s.SpawnBounds); + + s.Delete(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they pass (implementation already present)** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter LegacyHomeRangeTests` +Expected: PASS. If FAIL, the bug is in `BaseSpawner.OnAfterJsonDeserialize`'s homeRange block — fix there, not in the test. + +- [ ] **Step 3: Commit** + +```bash +git add Projects/UOContent.Tests/Tests/Engines/Spawners/Json/LegacyHomeRangeTests.cs +git commit -m "test(spawners): lock legacy homeRange->spawnBounds JSON read" +``` + +--- + +## Task 6: Rewire `ExportSpawnersCommand` to typed serialization + +**Files:** +- Modify: `Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs:71-101` + +**Interfaces:** +- Consumes: `SpawnerJsonSerializer.Options`. + +- [ ] **Step 1: Replace the DynamicJson export loop** + +In `ExportSpawnersCommand.ExecuteList`, replace the block that builds `List` (lines ~71-101) with a direct typed list. The current code: + +```csharp + var options = JsonConfig.GetOptions(new TextDefinitionConverterFactory()); + + var spawnRecords = new List(list.Count); + for (var i = 0; i < list.Count; i++) + { + if (list[i] is not BaseSpawner spawner || spawner.Map == Map.Internal || spawner.Parent != null) + { + continue; + } + + var dynamicJson = DynamicJson.Create(spawner.GetType()); + spawner.ToJson(dynamicJson, options); + spawnRecords.Add(dynamicJson); + } + + if (spawnRecords.Count == 0) + { + LogFailure("No matching spawners found."); + return; + } + + e.Mobile.SendMessage("Exporting spawners..."); + + JsonConfig.Serialize(path, spawnRecords, options); +``` + +becomes: + +```csharp + var spawnRecords = new List(list.Count); + for (var i = 0; i < list.Count; i++) + { + if (list[i] is not BaseSpawner spawner || spawner.Map == Map.Internal || spawner.Parent != null) + { + continue; + } + + spawnRecords.Add(spawner); + } + + if (spawnRecords.Count == 0) + { + LogFailure("No matching spawners found."); + return; + } + + e.Mobile.SendMessage("Exporting spawners..."); + + JsonConfig.Serialize(path, spawnRecords, SpawnerJsonSerializer.Options); +``` + +Remove now-unused `using` of `DynamicJson` if present and any unused `options` local. Keep `using Server.Json;` (for `JsonConfig`). + +- [ ] **Step 2: Build to verify it compiles** + +Run: `dotnet build Projects/UOContent/UOContent.csproj` +Expected: SUCCESS (the `Spawner.ToJson` overrides still exist; they are simply no longer called here). + +- [ ] **Step 3: Add an export round-trip test** + +`Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ExportImportFileTests.cs`: + +```csharp +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Server.Json; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class ExportImportFileTests +{ + [Fact] + public void Serialize_ThenDeserialize_File_PreservesSpawner() + { + var spawner = new Spawner(3, System.TimeSpan.FromMinutes(4), System.TimeSpan.FromMinutes(8), 1, + new Rectangle3D(200, 200, 0, 9, 9, 0), "Tanner"); + spawner.MoveToWorld(new Point3D(204, 204, 0), Map.Felucca); + + var path = Path.GetTempFileName(); + try + { + JsonConfig.Serialize(path, new List { spawner }, SpawnerJsonSerializer.Options); + + var loaded = JsonConfig.Deserialize>(path, SpawnerJsonSerializer.Options); + var s = Assert.IsType(Assert.Single(loaded)); + Assert.Equal(3, s.Count); + Assert.Equal(1, s.Team); + Assert.Equal(new Rectangle3D(200, 200, 0, 9, 9, 0), s.SpawnBounds); + + s.Delete(); + } + finally + { + File.Delete(path); + } + + spawner.Delete(); + } +} +``` + +- [ ] **Step 4: Run the test** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter ExportImportFileTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs \ + Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ExportImportFileTests.cs +git commit -m "refactor(spawners): export via typed SpawnerJsonSerializer" +``` + +--- + +## Task 7: Rewire `ImportSpawnersCommand` to typed deserialization + +**Files:** +- Modify: `Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs:193-274` +- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs` + +**Interfaces:** +- Consumes: `SpawnerJsonSerializer.Options`; `BaseSpawner.JsonLocation`/`JsonMap` are read back via public `Location`/`Map`? No — the deserialized spawner is not yet placed; read placement from the public `Location`/`Map` is invalid. Use the transient values exposed below. +- Produces: `BaseSpawner.ImportLocation` (`Point3D`) and `BaseSpawner.ImportMap` (`Map`) read-only accessors so the importer can place the deserialized spawner. + +- [ ] **Step 1: Expose import placement accessors on BaseSpawner** + +Add to `Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs` (these surface the captured transient values for the importer; not serialized): + +```csharp + [JsonIgnore] + public Point3D ImportLocation => _jsonLocation; + + [JsonIgnore] + public Map ImportMap => _jsonMap; +``` + +- [ ] **Step 2: Replace the import body** + +Replace `ImportJsonSpawners` (lines 193-274) with the typed flow. New body: + +```csharp + private static void ImportJsonSpawners( + Mobile from, + FileInfo file, + Dictionary allSpawners, + ref int totalGenerated, + ref int totalFailures + ) + { + List spawners; + try + { + spawners = JsonConfig.Deserialize>(file.FullName, SpawnerJsonSerializer.Options); + } + catch (JsonException) + { + from.SendMessage( + $"GenerateSpawners: Exception parsing {file.FullName}, file may not be in the correct format." + ); + return; + } + + if (spawners == null || spawners.Count == 0) + { + from.SendMessage($"GenerateSpawners: Skipping empty spawner file {file.Name}"); + logger.Information("{User} is skipping empty spawner file {File}", from, file.FullName); + return; + } + + using var queue = PooledRefQueue.Create(); + for (var i = 0; i < spawners.Count; i++) + { + var spawner = spawners[i]; + var location = spawner.ImportLocation; + var map = spawner.ImportMap; + + if (map == null || map == Map.Internal) + { + logger.Error($"Spawner {spawner.Guid} ({i}) has no valid map; skipping."); + spawner.Delete(); + totalFailures++; + continue; + } + + var type = spawner.GetType(); + + // Delete all spawners of the same concrete type already at this location. + foreach (var existing in map.GetItemsAt(location)) + { + if (existing.GetType() == type && existing != spawner) + { + queue.Enqueue(existing); + allSpawners.Remove(existing.Guid); + } + } + + while (queue.Count > 0) + { + queue.Dequeue().Delete(); + } + + try + { + spawner.MoveToWorld(location, map); + spawner.Respawn(); + + if (allSpawners.Remove(spawner.Guid, out var oldSpawner)) + { + oldSpawner.Delete(); + } + + allSpawners.Add(spawner.Guid, spawner); + totalGenerated++; + } + catch (Exception ex) + { + TraceException(ex, $"Failed to generate spawner {spawner.Guid}."); + spawner.Delete(); + totalFailures++; + } + } + } +``` + +Remove the now-unused `var options = JsonConfig.GetOptions();` and the `AssemblyHandler`/`CreateInstance` usings if they become unused. Keep `using Server.Json;`. + +> **Cleanup contract:** every deserialized spawner is either placed (`MoveToWorld`) or `Delete()`d — no orphaned Items. STJ constructs Items during deserialize (registered in World at `Map.Internal`); the `map == Map.Internal` filter and the `catch` both `Delete()`. + +- [ ] **Step 3: Write the cleanup test** + +```csharp +using System.Collections.Generic; +using System.IO; +using Server; +using Server.Engines.Spawners; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class ImportCleanupTests +{ + [Fact] + public void Import_ValidFile_PlacesSpawner() + { + var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "test.json"); + File.WriteAllText(path, """ + [ + { + "$type": "Spawner", + "guid": "11111111-1111-1111-1111-111111111111", + "location": [305, 305, 0], + "map": "Felucca", + "count": 1, + "spawnBounds": { "x1": 300, "y1": 300, "x2": 310, "y2": 310 }, + "entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ] + } + ] + """); + + try + { + ImportSpawnersCommand.GenerateFromFolder(dir); // see Step 4 note + var placed = Map.Felucca.GetItemsAt(new Point3D(305, 305, 0)); + var found = false; + foreach (var s in placed) + { + if (s.Guid == new System.Guid("11111111-1111-1111-1111-111111111111")) + { + found = true; + s.Delete(); + } + } + + Assert.True(found); + } + finally + { + Directory.Delete(dir, true); + } + } +} +``` + +> **Note:** if `ImportSpawnersCommand` has no folder-level public entry usable from a test, add an `internal static` test seam (e.g. `internal static void ImportFile(FileInfo file, Dictionary all)`) that wraps `ImportJsonSpawners`, and call that instead. Pick whichever already-exposed method maps cleanly; do not broaden visibility more than needed. Update the test call accordingly. + +- [ ] **Step 4: Run the test** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter ImportCleanupTests` +Expected: PASS. Adjust the test seam per the note if the build flags an inaccessible method. + +- [ ] **Step 5: Commit** + +```bash +git add Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs \ + Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs \ + Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs +git commit -m "refactor(spawners): import via typed polymorphic deserialization with cleanup" +``` + +--- + +## Task 8: One-time data migration to `$type` + +**Files:** +- Create: `tools/spawner-json-migrate/migrate.mjs` (Node, no server dependency) OR a `dotnet-script`/console one-off — use Node for zero build friction. +- Modify (output): `Distribution/Data/Spawns/**/*.json` +- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/MigratedDataLoadTests.cs` + +> **Rationale (spec §5.5):** a pure JSON transform preserves the per-file directory layout, which round-tripping through the in-game commands would not. The runtime keeps the `homeRange` read path (Task 5), so this transform cannot silently break loading. + +- [ ] **Step 1: Write the migration script** + +`tools/spawner-json-migrate/migrate.mjs`: + +```javascript +// One-time migration: rename "type" -> "$type" (kept first), and convert legacy +// homeRange (+location) -> spawnBounds, dropping homeRange. Run once, then delete this tool. +// +// Usage: node tools/spawner-json-migrate/migrate.mjs Distribution/Data/Spawns +import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +function* walk(dir) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) { + yield* walk(p); + } else if (name.endsWith(".json")) { + yield p; + } + } +} + +function toBounds(loc, hr) { + const [x, y, z] = loc; + if (hr === 0) { + return { x1: x, y1: y, z1: z, x2: x, y2: y, z2: z }; + } + // z = -128, depth 256 -> z2 = 127. Rectangle3D start..end inclusive of width/height. + return { x1: x - hr, y1: y - hr, z1: -128, x2: x + hr, y2: y + hr, z2: 127 }; +} + +function migrateOne(obj) { + const out = {}; + // $type first. + if ("type" in obj) { + out["$type"] = obj["type"]; + } else if ("$type" in obj) { + out["$type"] = obj["$type"]; + } + for (const [k, v] of Object.entries(obj)) { + if (k === "type" || k === "$type") { + continue; + } + if (k === "homeRange") { + if (obj.spawnBounds === undefined && Array.isArray(obj.location)) { + out.spawnBounds = toBounds(obj.location, v); + } + continue; // drop homeRange + } + out[k] = v; + } + return out; +} + +let files = 0; +let records = 0; +for (const file of walk(process.argv[2])) { + const data = JSON.parse(readFileSync(file, "utf8")); + const arr = Array.isArray(data) ? data : [data]; + const migrated = arr.map((o) => { + records++; + return migrateOne(o); + }); + const result = Array.isArray(data) ? migrated : migrated[0]; + writeFileSync(file, JSON.stringify(result, null, 2) + "\n", "utf8"); + files++; +} +console.log(`Migrated ${records} record(s) across ${files} file(s).`); +``` + +> **Bounds formula note:** the runtime homeRange→bounds uses `Rectangle3D(x-hr, y-hr, -128, hr*2+1, hr*2+1, 256)`. The `Rectangle3DConverter` object form is `{x1,y1,z1,x2,y2,z2}` where the rectangle spans start..end. `x2 = x+hr` gives width `hr*2+1` (inclusive), `z1=-128,z2=127` gives depth 256. Task 5's equivalence test (Step 4) validates this against the runtime read; if it disagrees, fix `toBounds` to match `BaseSpawner.OnAfterJsonDeserialize`. + +- [ ] **Step 2: Run the migration** + +Run: `node tools/spawner-json-migrate/migrate.mjs Distribution/Data/Spawns` +Expected: prints a non-zero migrated count. `git status` shows many modified `Distribution/Data/Spawns/**/*.json`. + +- [ ] **Step 3: Spot-check a migrated file** + +Run: `git diff -- Distribution/Data/Spawns/post-uoml/felucca/Vendors.json | head -40` +Expected: `"type"` → `"$type"` (first key); any `homeRange` replaced by a `spawnBounds` object; no other semantic changes. + +- [ ] **Step 4: Write the migrated-data load + equivalence test** + +```csharp +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class MigratedDataLoadTests +{ + [Fact] + public void MigratedFile_LoadsWithDollarType() + { + var path = Path.Combine(Core.BaseDirectory, "Data", "Spawns", "post-uoml", "felucca", "Vendors.json"); + if (!File.Exists(path)) + { + return; // distribution data not present in this checkout + } + + var loaded = JsonConfig_DeserializeList(path); + Assert.NotEmpty(loaded); + foreach (var s in loaded) + { + Assert.IsAssignableFrom(s); + s.Delete(); + } + } + + private static List JsonConfig_DeserializeList(string path) => + JsonSerializer.Deserialize>(File.ReadAllText(path), SpawnerJsonSerializer.Options); +} +``` + +- [ ] **Step 5: Run the test** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter MigratedDataLoadTests` +Expected: PASS. + +- [ ] **Step 6: Commit (data + tool together, then remove the tool)** + +```bash +git add Distribution/Data/Spawns tools/spawner-json-migrate \ + Projects/UOContent.Tests/Tests/Engines/Spawners/Json/MigratedDataLoadTests.cs +git commit -m "chore(spawners): migrate spawn data to \$type discriminator" +git rm -r tools/spawner-json-migrate +git commit -m "chore(spawners): remove one-time spawn migration tool" +``` + +--- + +## Task 9: Delete `DynamicJson` and the old per-class JSON members + +**Files:** +- Delete: `Projects/Server/Json/DynamicJson.cs` +- Modify: `Projects/UOContent/Engines/Spawners/BaseSpawner.cs` (remove `(DynamicJson, options)` ctor + `ToJson`) +- Modify: `Projects/UOContent/Engines/Spawners/Spawner.cs`, `RegionSpawner.cs`, `ProximitySpawner.cs` (remove `(DynamicJson,…)` ctor + `ToJson` override) + +- [ ] **Step 1: Remove the old members** + +In `BaseSpawner.cs`, delete the entire `public BaseSpawner(DynamicJson json, JsonSerializerOptions options)` constructor (lines ~297-356) and the `public virtual void ToJson(DynamicJson json, JsonSerializerOptions options)` method (lines ~496-547). In `Spawner.cs`, `RegionSpawner.cs`, and `ProximitySpawner.cs`, delete each `(DynamicJson, options)` constructor and each `ToJson` override. Remove now-unused `using Server.Json;` / `using System.Text.Json;` lines flagged by the compiler. + +- [ ] **Step 2: Delete the file** + +Run: `git rm Projects/Server/Json/DynamicJson.cs` + +- [ ] **Step 3: Verify no remaining references** + +Run: `git grep -n "DynamicJson"` +Expected: no matches (empty output). + +- [ ] **Step 4: Build the solution** + +Run: `dotnet build ModernUO.sln` +Expected: SUCCESS, no errors. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(json): delete DynamicJson; spawners use typed STJ exclusively" +``` + +--- + +## Task 10: Load-all regression over `Spawns/` + +**Files:** +- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class AllSpawnFilesLoadTests +{ + [Fact] + public void EverySpawnFile_DeserializesWithoutThrowing() + { + var root = Path.Combine(Core.BaseDirectory, "Data", "Spawns"); + if (!Directory.Exists(root)) + { + return; // distribution data not present + } + + var failures = new List(); + foreach (var file in Directory.EnumerateFiles(root, "*.json", SearchOption.AllDirectories)) + { + try + { + var list = JsonSerializer.Deserialize>( + File.ReadAllText(file), SpawnerJsonSerializer.Options); + if (list != null) + { + foreach (var s in list) + { + s?.Delete(); + } + } + } + catch (JsonException ex) + { + failures.Add($"{file}: {ex.Message}"); + } + } + + Assert.True(failures.Count == 0, string.Join("\n", failures)); + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter AllSpawnFilesLoadTests` +Expected: PASS. Any failure names the offending file — fix the migration (Task 8) or converter, not the test. + +- [ ] **Step 3: Commit** + +```bash +git add Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs +git commit -m "test(spawners): assert all migrated spawn files deserialize" +``` + +--- + +## Task 11: Startup validation tests (collision + constructibility) + +**Files:** +- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs` + +These exercise the `Collect` validation in isolation via a small reflection-free helper. Because `Configure()` scans real assemblies, test the validation logic by calling a refactored internal helper. + +- [ ] **Step 1: Expose an internal validation helper** + +Refactor `SpawnerJsonSerializer.Collect` to delegate discriminator/constructibility validation to an `internal static` method so tests can call it without a full assembly scan: + +```csharp + internal static (string discriminator, JsonDerivedType derived) Validate( + Type type, Dictionary byDiscriminator) + { + if (!IsJsonConstructible(type)) + { + throw new Exception( + $"Spawner type '{type.FullName}' is marked [JsonDiscoverableType] but System.Text.Json cannot construct it. " + + "Add a public parameterless constructor marked [JsonConstructor]."); + } + + var attr = (JsonDiscoverableTypeAttribute)Attribute.GetCustomAttribute( + type, typeof(JsonDiscoverableTypeAttribute), false); + var discriminator = attr?.Discriminator ?? type.Name; + if (byDiscriminator.TryGetValue(discriminator, out var existing)) + { + throw new Exception( + $"Spawner JSON discriminator '{discriminator}' is claimed by both '{existing.FullName}' and " + + $"'{type.FullName}'. Set an explicit discriminator via [JsonDiscoverableType(\"...\")] on one."); + } + + return (discriminator, new JsonDerivedType(type, discriminator)); + } +``` + +Have `Collect` call `Validate` and add to its maps/list. Add `[assembly: InternalsVisibleTo("UOContent.Tests")]` to UOContent if not already present (check `Projects/UOContent/Properties/` or an existing `AssemblyInfo`; if `SectorSpawnCacheTests` already touches internals, it is present). + +- [ ] **Step 2: Write the tests** + +```csharp +using System; +using System.Collections.Generic; +using Server.Engines.Spawners; +using Server.Json; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +public class SpawnerDiscoveryValidationTests +{ + [JsonDiscoverableType("dup")] + private sealed class DupA : Spawner { [System.Text.Json.Serialization.JsonConstructor] public DupA() { } } + + [JsonDiscoverableType("dup")] + private sealed class DupB : Spawner { [System.Text.Json.Serialization.JsonConstructor] public DupB() { } } + + [Fact] + public void DuplicateDiscriminator_Throws() + { + var map = new Dictionary(); + var (disc, _) = SpawnerJsonSerializer.Validate(typeof(DupA), map); + map[disc] = typeof(DupA); + + var ex = Assert.Throws(() => SpawnerJsonSerializer.Validate(typeof(DupB), map)); + Assert.Contains("discriminator 'dup'", ex.Message); + } +} +``` + +> A non-constructible negative test is hard to express cleanly (any nested class can declare a ctor). The constructibility branch is covered by the production `Configure()` path; the duplicate-discriminator test is the high-value case. Do not add a contrived non-constructible type just to hit the branch. + +- [ ] **Step 3: Run the tests** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter SpawnerDiscoveryValidationTests` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add Projects/UOContent/Engines/Spawners/SpawnerJsonSerializer.cs \ + Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs +git commit -m "test(spawners): validate duplicate JSON discriminator detection" +``` + +--- + +## Task 12: Full suite + spec sync + +**Files:** +- Modify: `docs/superpowers/specs/2026-06-25-spawner-stj-migration-design.md` (note the D1 refinement) + +- [ ] **Step 1: Run the full UOContent + Server suites** + +Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj` and `dotnet test Projects/Server.Tests/Server.Tests.csproj` +Expected: PASS (no regressions). Pay attention to any spawner-related world-save tests. + +- [ ] **Step 2: Update the spec's D1 note** + +In the design doc, append to the D1 row / §5.2 that sparse output is realized via nullable shadow getters + `WhenWritingNull` (equivalent output, simpler than `ShouldSerialize`). + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/specs/2026-06-25-spawner-stj-migration-design.md +git commit -m "docs(spawners): note D1 sparse-output mechanism refinement" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Delete DynamicJson → Task 9. ✓ +- Typed STJ spawners (no DTOs) → Tasks 2–4. ✓ +- Exact sparse output (D1) → nullable shadow getters (Tasks 2–4) + omission tests (Task 2 Step 1). ✓ +- Auto-discovery, no registration (D4) → `SpawnerJsonSerializer.Configure` (Task 2). ✓ +- Reusable opt-in marker (D5) → Task 1; wired for spawners only. ✓ +- `$type` migration (D2) → Task 8. ✓ +- Legacy `homeRange` read-legacy/write-modern (D3) → Tasks 2 (write-modern: getter always null) + 5 (read-legacy test). ✓ +- Loud collision/constructibility validation → Tasks 2 (`Collect`) + 11. ✓ +- Import cleanup contract → Task 7. ✓ +- Testing (round-trip, load-all, legacy, sparse, polymorphism, validation) → Tasks 2–7, 10, 11. ✓ + +**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The one conditional (Task 7 test seam) gives an explicit decision rule, not a vague instruction. + +**Type consistency:** `OnAfterJsonDeserialize` (`protected internal virtual`/`override`) consistent across Tasks 2–4. `SpawnerJsonSerializer.Options`/`Configure`/`Validate` signatures consistent across Tasks 2, 6, 7, 11. Shadow property names (`Json*` + `[JsonPropertyName]` lowercase keys) consistent. `ImportLocation`/`ImportMap` defined in Task 7 Step 1, used Task 7 Step 2. + +**Open risk to watch during execution (flag, do not pre-solve):** STJ constructor selection — `[JsonConstructor]` on a parameterless ctor that is *also* `[Constructible]`. If STJ rejects it or picks the generated `(Serial)` ctor, the Task 2 round-trip test fails at deserialize; the fix is to ensure exactly one parameterless `[JsonConstructor]` per concrete type (already specified). The `[Collection("Sequential UOContent Tests")]` fixture must expose `Map.Felucca` with tile data — `TestServerInitializer` loads maps, so this holds. diff --git a/docs/superpowers/specs/2026-06-25-spawner-stj-migration-design.md b/docs/superpowers/specs/2026-06-25-spawner-stj-migration-design.md new file mode 100644 index 000000000..2b26782f7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-spawner-stj-migration-design.md @@ -0,0 +1,248 @@ +# Remove `DynamicJson`, migrate spawners to typed System.Text.Json + +- **Date:** 2026-06-25 +- **Status:** Approved (design); implementation plan pending +- **Author:** Kamron Batman (with Claude) +- **Scope:** `Projects/Server/Json/`, `Projects/UOContent/Engines/Spawners/`, `Distribution/Data/Spawns/` + +## 1. Problem + +`Projects/Server/Json/DynamicJson.cs` is a dated, hand-rolled JSON envelope — +a `string Type` discriminator plus a `[JsonExtensionData] Dictionary` +bag with manual `GetProperty` / `SetProperty` helpers. `SetProperty` round-trips +every value through `JsonSerializer` → `JsonDocument.Parse` → `.Clone()`, and the file +itself carries a `// TODO: Use JSON Node in .NET 6` note. It is the **only** remaining +consumer of this pattern; regions were migrated off it in #1400. + +`DynamicJson` is used exclusively by spawners: + +- `BaseSpawner`/`Spawner`/`RegionSpawner`/`ProximitySpawner` each hand-write a + `(DynamicJson, options)` constructor **and** a `ToJson(...)` method, reading/writing + every field by string key. +- `ImportSpawnersCommand` deserializes `List`, resolves the concrete type + via `AssemblyHandler.FindTypeByName(json.Type)`, and reflection-instantiates via + `type.CreateInstance(json, options)`. +- `ExportSpawnersCommand` builds `DynamicJson.Create(...)` + `spawner.ToJson(...)`. + +The codebase already has the better pattern in two places: **regions** +(`RegionJsonSerializer` — typed polymorphism + a `TypeInfoResolver`) and, more directly, +**`SpawnerEntry`**, which is already a typed POCO deserialized by STJ via `[JsonConstructor]` +and `[SerializedJsonPropertyName]` (a source-generator hook that emits `[JsonPropertyName]`). +Spawners are the last holdout. + +### Why now / why not "because .NET 10" + +The enabling STJ features — polymorphism, `[JsonConstructor]`, resolver `ShouldSerialize` +modifiers — landed in .NET 7–8 and regions already use them. **This work is justified by +consistency, compile-time type safety, and deleting dated code, not by a new .NET 10 +feature.** The PR should be framed that way. + +## 2. Goals + +- Delete `DynamicJson` entirely. +- Make spawners directly STJ-(de)serializable, mirroring the regions pattern and the + existing `SpawnerEntry` precedent. +- Preserve **exact** sparse export output (no field churn vs. today). +- Preserve **zero-config extensibility** — operators must not be forced to maintain a + manual registration list (unlike regions' `RegionJsonRegistration`). +- Migrate the checked-in spawn data to the `$type` discriminator, aligning with regions. + +## 3. Non-goals + +- Retrofitting **regions** with the new discovery/opt-in attribute (possible follow-up; + the attribute is designed to allow it, but it is not wired up here). +- Changing spawner **binary world-save** serialization (the `[SerializationGenerator]` + layer) — untouched. +- Changing spawner runtime behavior, spawn logic, or the import/export command UX. + +## 4. Decisions (locked) + +| # | Decision | Rationale | +|---|----------|-----------| +| D1 | **Match current sparse output exactly** via `JsonPropertyInfo.ShouldSerialize` modifiers in a `TypeInfoResolver`. | `WhenWritingDefault` only omits CLR defaults; spawners omit *domain* defaults (5-min delay, `maxSpawnAttempts == 10`, `spawnPositionMode` Automatic/Abandoned, `walkingRange == -1`, …). The same predicates already exist for the binary layer (`ShouldSerializeMinDelay()` etc.) and are reused. | +| D2 | **Migrate data files to `$type`** (one-time conversion). | Aligns spawners with regions' STJ-default discriminator. Existing `$type` values stay the short type name (`"Spawner"`), so only the key changes. | +| D3 | **Legacy `homeRange`: read-legacy / write-modern.** | The runtime keeps reading `homeRange` (→ `spawnBounds`) for old/external files; regenerated files contain only `spawnBounds`. No input-compat break, clean output forward. | +| D4 | **Auto-discovery, no manual registration.** | Today's `FindTypeByName` path needs zero registration; a `Register()` list (regions-style) would be a *regression* and re-introduce the known footgun ("people forget to register"). | +| D5 | **Opt-in via a dedicated, reusable marker attribute** with optional discriminator override. Wire discovery for the `BaseSpawner` hierarchy only in this PR. | Decouples opt-in from STJ's constructor-count rules (so it generalizes), is copy-paste-safe, and the optional override resolves name collisions without class renames. | + +## 5. Architecture + +Six components. Only one Server-side change is a deletion (`DynamicJson.cs`); the new +spawner serializer lives in UOContent. + +### 5.1 Reusable opt-in marker attribute (`Projects/Server/Json/`) + +A class-level attribute marking a concrete type as a **discoverable polymorphic JSON +derived type**, with an optional discriminator override. Lives in `Server.Json` alongside +`SerializedJsonPropertyNameAttribute`. + +```csharp +// Name is a plan-level detail; e.g. JsonDiscoverableTypeAttribute / DiscoverableJsonTypeAttribute. +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class JsonDiscoverableTypeAttribute : Attribute +{ + public JsonDiscoverableTypeAttribute(string discriminator = null) => Discriminator = discriminator; + public string Discriminator { get; } // null => use Type.Name +} +``` + +- **Not** coupled to spawners — it only declares "this concrete class is discoverable for + STJ polymorphism." The *consumer* (the spawner serializer) decides which base hierarchy + to scan. +- `Inherited = false`: a subclass must declare its own intent (no accidental inheritance). +- This is a **runtime-discovery** attribute, distinct from the codegen `Serialized*` + family — it does not drive the source generator. + +### 5.2 `SpawnerJsonSerializer` (`Projects/UOContent/Engines/Spawners/`) + +Mirrors `RegionJsonSerializer`, but discovers derived types instead of requiring +registration. Placed in UOContent because `BaseSpawner` is UOContent — keeps Server edits +to the `DynamicJson` deletion only. + +Responsibilities: + +1. **Discovery (`Configure` phase).** Scan `AssemblyHandler.GetTypeCache(asm).Types` + across all assemblies (same enumeration `Main.cs:VerifyType` uses) for types where + `IsAssignableTo(typeof(BaseSpawner)) && !IsAbstract` **and** bearing the marker + attribute. Build the `JsonDerivedType[]` as `new(t, attr.Discriminator ?? t.Name)`. + - Triggered via the standard `Invoke("Configure")` bootstrap (a static `Configure()`). + - Timing is safe: spawner JSON is only (de)serialized at import/export command time, + long after `Configure`. + +2. **`TypeInfoResolver`** (a `DefaultJsonTypeInfoResolver` with modifiers): + - *Modifier A — polymorphism:* on `typeInfo.Type == typeof(BaseSpawner)`, set + `PolymorphismOptions` and add the discovered `DerivedTypes`. Discriminator key stays + STJ-default `$type`. + - *Modifier B — sparse output (D1):* on types assignable to `BaseSpawner`, set + `JsonPropertyInfo.ShouldSerialize` for the domain-default fields, reusing the existing + default conditions (`_minDelay != DefaultMinDelay`, `_maxDelay != DefaultMaxDelay`, + `_team != 0`, `_maxSpawnAttempts != DefaultMaxSpawnAttempts`, `spawnPositionMode` + not Automatic/Abandoned, `walkingRange != -1`, optional name/etc.). + - *Modifier C — post-construct (`OnDeserialized`):* run `InitSpawn(...)`, wire up + `entries` (`AddEntry` per entry), and perform the legacy `homeRange` → `spawnBounds` + conversion (D3) using the captured location. + +3. **Options:** `new(JsonConfig.DefaultOptions) { DefaultIgnoreCondition = + WhenWritingDefault, TypeInfoResolver = … }` — same shape as `RegionJsonSerializer._options`. + The `TextDefinitionConverterFactory` (used by `ProximitySpawner.spawnMessage`) is added + as it is today. + +4. **Startup validation (loud):** + - *Collision:* two discovered types resolving to the same discriminator → throw at + `Configure` with both full names; resolvable by setting an explicit override. + - *Constructibility:* a discovered (opted-in) type that STJ cannot construct (no single + public ctor and no `[JsonConstructor]`) → throw at `Configure` with the full name, + converting a first-import failure into a boot-time failure. + +### 5.3 Spawner class changes + +`Spawner` / `RegionSpawner` / `ProximitySpawner` become STJ-deserializable like +`SpawnerEntry`: + +- Add the **marker attribute** to each concrete class. +- Annotate JSON-bound fields with `[SerializedJsonPropertyName("…")]` (generator emits + `[JsonPropertyName]`); add a minimal `[JsonConstructor]` per concrete class chaining the + `Item` base ctor. (Each is required because Items always have multiple constructors, so + STJ needs the attribute to disambiguate.) +- **Delete** every `(DynamicJson, options)` constructor and every `ToJson(...)` method. +- `location` / `map`: expose JSON-only accessors on `BaseSpawner` that **read** live + `Item.Location`/`Item.Map` for export and **capture** an import target for placement. + These are *not* `[SerializableField]` (binary save untouched) — plain + `[JsonInclude]`/`[JsonPropertyName]` members backed by transient fields. +- `homeRange`: a settable, write-ignored legacy property feeding the `OnDeserialized` + conversion; `spawnBounds` is the modern field. + +### 5.4 Import / Export command rewire + +- **Export** (`ExportSpawnersCommand`): `JsonConfig.Serialize(spawners, options)` over the + filtered `List` directly. Remove `DynamicJson.Create`/`ToJson`. +- **Import** (`ImportSpawnersCommand`): `JsonConfig.Deserialize>(file, + options)` — polymorphism yields concrete spawner Items directly; remove `FindTypeByName` + + `CreateInstance`. Read placement from each spawner's JSON `location`/`map` accessors, + then `MoveToWorld` + `Respawn`, preserving today's dedup-by-location/type replacement. + - **Cleanup contract:** STJ constructs each Item (serial assigned, World-registered) + during deserialize, before validation. Any spawner the importer then rejects/replaces + must be `Delete()`d so no orphaned Items leak. A malformed file mid-parse throws + `JsonException` (already caught) — any partially-constructed items from that file must + also be cleaned up. + +### 5.5 One-time data migration (`Distribution/Data/Spawns/**`) + +A **pure JSON transform** (no game world), preserving the per-file directory layout: + +- Rename `"type"` → `"$type"`, kept as the **first** property (STJ requires the + discriminator first for polymorphic reads). +- Convert `homeRange` (+ `location`) → `spawnBounds` via the documented formula + (`homeRange == 0` → `z = location.Z, depth = 0`; else `z = -128, depth = 256`; + `Rectangle3D(x-hr, y-hr, z, hr*2+1, hr*2+1, depth)`), then drop `homeRange`. +- Chosen over round-tripping through the in-game import/export commands because those + flatten everything into a single output file and would destroy the + `felucca/Vendors.json` directory structure. +- **Safety net:** because the runtime retains the `homeRange` read path (D3), an imperfect + conversion cannot silently break loading — a stale `homeRange` still loads correctly. + A test asserts converter output equals the runtime legacy read for a sample. + +### 5.6 Delete `DynamicJson` + +Remove `Projects/Server/Json/DynamicJson.cs` and all references. Keep `JsonConfig`, the +converters, and `SerializedJsonPropertyNameAttribute`. `type.CreateInstance<>` stays as a +general utility (just no longer used by import). + +## 6. Data flow + +- **Export:** live `List` → `JsonConfig.Serialize` (resolver: polymorphism + writes `$type`; `ShouldSerialize` omits domain defaults) → file. +- **Import / `[GenerateSpawners`:** file → `Deserialize>` → STJ picks + concrete type by `$type`, sets properties, runs `OnDeserialized` (`InitSpawn`, entries, + `homeRange`→`spawnBounds`) → importer places (`MoveToWorld`) + `Respawn` + dedup. +- **World save/load:** unchanged — binary `[SerializationGenerator]` path, independent of + JSON. + +## 7. Backward compatibility + +- Existing files must load after the one-time `$type` migration. The discriminator values + are unchanged (short type names). +- External/un-migrated files still using `homeRange` continue to load (D3). External files + still using `"type"` (not `$type`) will **not** load — an accepted consequence of D2. +- `SpawnerEntry` JSON shape is unchanged. + +## 8. Error handling + +- **Discriminator collision** → throw at `Configure` (both full names; fix via override). +- **Opted-in but not STJ-constructible** → throw at `Configure` (full name). +- **Malformed import file** → `JsonException`, caught as today; partial Items cleaned up. +- **Unknown `$type`** (type not discovered/opted-in) → STJ throws; surfaced as an import + failure for that file, logged. + +## 9. Testing + +- **Round-trip:** deserialize a migrated sample → serialize → assert structural equality + and `$type` first. +- **Load-all:** every migrated file under `Spawns/` deserializes without throwing. +- **Legacy read:** a `homeRange`-only file (no `spawnBounds`) yields the correct bounds. +- **Migration equivalence:** converter output equals the runtime legacy read for a sample. +- **Sparse output:** domain-default fields omitted; non-defaults written (per-field). +- **Polymorphism:** each concrete type round-trips to the correct `$type`; a custom + marked subclass is discovered and round-trips. +- **Validation:** duplicate discriminator and non-constructible opted-in type each throw + at `Configure`. +- **Import cleanup:** a malformed/rejected entry leaves no orphaned Items in the World. + +## 10. Risks & mitigations + +- **Sparse-output parity drift** → driven by the same predicates as the binary layer; + covered by per-field tests. +- **`$type`-first requirement** → the migration keeps the discriminator first; round-trip + test asserts it. +- **Item-construction-during-deserialize side effects** → explicit cleanup contract (5.4) + + import-cleanup test. +- **`homeRange` formula duplicated in the migration tool** → small/pure; equivalence test + cross-checks against the canonical runtime read. +- **Discoverability of opt-in** → loud `Configure`-time validation; copy-paste convention + carries the marker. + +## 11. Out of scope / follow-ups + +- Retire regions' `RegionJsonRegistration.Register()` list using the same discovery + + marker attribute. +- Replace `DynamicJson.SetProperty`'s round-trip with `JsonNode` — moot once deleted.