diff --git a/docs/superpowers/plans/2026-06-25-spawner-stj-migration-v2-dto.md b/docs/superpowers/plans/2026-06-25-spawner-stj-migration-v2-dto.md deleted file mode 100644 index cb8c99f4d..000000000 --- a/docs/superpowers/plans/2026-06-25-spawner-stj-migration-v2-dto.md +++ /dev/null @@ -1,967 +0,0 @@ -# Spawner STJ Migration v2 (DTO records) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. - -> **Supersedes** `2026-06-25-spawner-stj-migration.md` (Approach A). See the design spec §0 Revision for why: deserializing directly into the live spawner `Item` lets STJ construct world-registered objects before validation, leaking them on malformed input. Approach B deserializes to plain `record` DTOs (failure = GC only), then a factory builds the spawner. - -**Goal:** Delete `DynamicJson`; spawners (de)serialize through a polymorphic `SpawnerDto` record hierarchy — STJ never touches a live `Item`. - -**Architecture:** `abstract record SpawnerDto` + one concrete record per spawner type, each `[JsonDiscoverableType]`. Import: `Deserialize>` → `dto.ToSpawner()`. Export: `spawner.ToDto()` → `Serialize(List)`. `SpawnerJsonSerializer` discovers `SpawnerDto` subtypes and wires `$type` polymorphism (no property pruning, no `OnDeserialized`). - -**Tech Stack:** .NET 10, System.Text.Json, xUnit, ModernUO source-generated serialization. - -## Current state (Approach A already on the branch) - -Tasks 1–7 of the v1 plan are committed (HEAD `30d154286`). **Task 1's marker attribute (`Server.Json.JsonDiscoverableTypeAttribute`) carries over unchanged.** The Approach-A artifacts below are REPLACED by this plan and must be removed where noted: -- `BaseSpawner.Json.cs`, `Spawner.Json.cs`, `RegionSpawner.Json.cs`, `ProximitySpawner.Json.cs` (shadow properties + `OnAfterJsonDeserialize` + `ImportLocation`/`ImportMap`) → **deleted**, replaced by DTO records + `BaseSpawner.Dto.cs`. -- `[JsonDiscoverableType]` + `[JsonConstructor]` on `Spawner`/`RegionSpawner`/`ProximitySpawner` → **removed** (the marker moves to the DTO records; STJ never constructs the Items). -- `SpawnerJsonSerializer` prune + `OnDeserialized` resolver modifiers → **removed**; discovery retargets to `SpawnerDto`. -- Export/import command rewires (A) → **replaced** with the DTO versions here. -- A round-trip tests (`SpawnerRoundTripTests`, `RegionSpawnerRoundTripTests`, `ProximitySpawnerRoundTripTests`, `ExportImportFileTests`, `ImportCleanupTests`, `LegacyHomeRangeTests`) → **replaced/adapted** to DTOs. - -The net branch diff vs `main` will be Approach B; the A commits remain in history. - -## Global Constraints - -- **Single-threaded.** No `lock`/`volatile`/`Concurrent*`/`Task.Run`/`new Thread()` in game code. (Migration tool is offline.) -- **Server changes:** keep `JsonDiscoverableTypeAttribute`; **delete** `DynamicJson.cs` (Task 7). No other Server edits. -- **Do not touch binary world-save serialization** (`[SerializationGenerator]`, `[SerializableField]`, `Deserialize(reader, version)`, `MigrateFrom`). -- **Braces on all control flow.** `_camelCase` private fields, `PascalCase` public/record members. -- **No `Console.WriteLine`** — `LogFactory.GetLogger(...)`. -- **Sparse output must match today's `ToJson`** — realized via **nullable DTO properties** + `WhenWritingNull` (the default in `JsonConfig.GetOptions`). The `maxSpawnAttempts` getter omits BOTH `0` and `DefaultMaxSpawnAttempts(10)` (runtime treats `0` as default — `BaseSpawner.cs:~568`). -- **Discriminator:** STJ-default `$type`; value = `type.Name` of the **DTO** unless overridden, but the wire value must be the SPAWNER name (`"Spawner"`, `"RegionSpawner"`, `"ProximitySpawner"`) for data compatibility — so each DTO sets an explicit discriminator override matching the spawner type name. -- **Records use init-only properties** (not positional) so STJ uses the public parameterless ctor; no `[JsonConstructor]` needed. -- **Test hygiene:** any spawner `MoveToWorld`'d OR produced by `ToSpawner()` must be `?.Delete()`d in a `finally` block (declared before `try`); temp files/dirs too. Tests share `[Collection("Sequential UOContent Tests")]`. -- **Build:** `dotnet build ModernUO.sln`. **Test:** `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj`. - ---- - -## File Structure - -**Create:** -- `Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs` — `abstract record SpawnerDto` + `SpawnerDataDto`, `RegionSpawnerDto`, `ProximitySpawnerDto`. -- `Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs` — `internal void ApplyDto(SpawnerDto)` (import population) + `private protected` `Dto*` export getters. -- `Projects/UOContent/Engines/Spawners/Spawner.Dto.cs`, `RegionSpawner.Dto.cs`, `ProximitySpawner.Dto.cs` — `public override SpawnerDto ToDto()`. - -**Modify:** -- `SpawnerJsonSerializer.cs` — retarget discovery to `SpawnerDto`; drop prune + `OnDeserialized`. -- `Spawner.cs`/`RegionSpawner.cs`/`ProximitySpawner.cs` — remove `[JsonDiscoverableType]` + `[JsonConstructor]` (added in A); add `abstract SpawnerDto ToDto()` on `BaseSpawner`. -- Export/Import commands. -- Tests. - -**Delete:** `BaseSpawner.Json.cs`, `Spawner.Json.cs`, `RegionSpawner.Json.cs`, `ProximitySpawner.Json.cs` (A artifacts); `DynamicJson.cs` (Task 7). - ---- - -## Task 2: DTO record hierarchy + serializer retarget (core conversion) - -Supersedes A Tasks 2–4. Replaces the shadow-property mechanism with DTO records. - -**Files:** -- Create: `Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs` -- Create: `Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs` -- Create: `Projects/UOContent/Engines/Spawners/Spawner.Dto.cs`, `RegionSpawner.Dto.cs`, `ProximitySpawner.Dto.cs` -- Modify: `SpawnerJsonSerializer.cs`; `BaseSpawner.cs` (add `abstract ToDto`); `Spawner.cs`/`RegionSpawner.cs`/`ProximitySpawner.cs` (remove A attributes) -- Delete: `BaseSpawner.Json.cs`, `Spawner.Json.cs`, `RegionSpawner.Json.cs`, `ProximitySpawner.Json.cs` -- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs` (replaces the three A round-trip test files — delete those) - -**Interfaces:** -- Produces: `Server.Engines.Spawners.SpawnerDto` (abstract, `[JsonDerivedType]`-discovered) with `BaseSpawner ToSpawner()`; `BaseSpawner.ToDto()` (`public abstract SpawnerDto`); `BaseSpawner.ApplyDto(SpawnerDto)` (`internal`); `SpawnerJsonSerializer.Options`/`Configure()` retargeted to `SpawnerDto`. -- Consumes: `Server.Json.JsonDiscoverableTypeAttribute` (Task 1). - -- [ ] **Step 1: Write the failing test** - -`SpawnerDtoRoundTripTests.cs`: - -```csharp -using System; -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 SpawnerDtoRoundTripTests -{ - [Fact] - public void Spawner_RoundTrips_ThroughDto() - { - Spawner original = null; - BaseSpawner rebuilt = null; - try - { - original = new Spawner(2, TimeSpan.FromMinutes(3), TimeSpan.FromMinutes(7), 0, - new Rectangle3D(100, 100, 0, 5, 5, 0), "Fisherman"); - original.MoveToWorld(new Point3D(105, 105, 0), Map.Felucca); - - var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); - Assert.Contains("\"$type\": \"Spawner\"", json); - Assert.Contains("\"count\": 2", json); - - var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); - rebuilt = Assert.Single(dtos).ToSpawner(); - var s = Assert.IsType(rebuilt); - Assert.Equal(2, s.Count); - Assert.Equal(TimeSpan.FromMinutes(3), s.MinDelay); - Assert.Equal(new Rectangle3D(100, 100, 0, 5, 5, 0), s.SpawnBounds); - Assert.Equal("Fisherman", Assert.Single(s.Entries).SpawnedName); - } - finally - { - rebuilt?.Delete(); - original?.Delete(); - } - } - - [Fact] - public void Spawner_OmitsDomainDefaults() - { - Spawner original = null; - try - { - original = new Spawner("Fisherman"); - original.MoveToWorld(new Point3D(110, 110, 0), Map.Felucca); - var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); - Assert.DoesNotContain("minDelay", json); - Assert.DoesNotContain("maxDelay", json); - Assert.DoesNotContain("\"team\"", json); - Assert.DoesNotContain("maxSpawnAttempts", json); - } - finally - { - original?.Delete(); - } - } - - [Fact] - public void RegionSpawner_RoundTrips_RegionByName() - { - var region = new BaseRegion("DtoTestRegion", Map.Felucca, 50, new Rectangle3D(1400, 1670, 0, 40, 40, 0)); - region.Register(); - RegionSpawner original = null; - BaseSpawner rebuilt = null; - try - { - original = new RegionSpawner("Fisherman") { SpawnRegion = region }; - original.MoveToWorld(new Point3D(1416, 1683, 0), Map.Felucca); - var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); - Assert.Contains("\"$type\": \"RegionSpawner\"", json); - Assert.Contains("DtoTestRegion", json); - - var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); - rebuilt = Assert.Single(dtos).ToSpawner(); - Assert.Equal("DtoTestRegion", Assert.IsType(rebuilt).SpawnRegion?.Name); - } - finally - { - rebuilt?.Delete(); - original?.Delete(); - region.Unregister(); - } - } - - [Fact] - public void ProximitySpawner_RoundTrips_Fields() - { - ProximitySpawner original = null; - BaseSpawner rebuilt = null; - try - { - original = new ProximitySpawner("Fisherman") { TriggerRange = 4, InstantFlag = true, SpawnMessage = 500000 }; - original.MoveToWorld(new Point3D(120, 120, 0), Map.Felucca); - var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); - Assert.Contains("\"$type\": \"ProximitySpawner\"", json); - Assert.Contains("\"triggerRange\": 4", json); - Assert.Contains("\"instant\": true", json); - - var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); - rebuilt = Assert.Single(dtos).ToSpawner(); - var p = Assert.IsType(rebuilt); - Assert.Equal(4, p.TriggerRange); - Assert.True(p.InstantFlag); - Assert.Equal(500000, p.SpawnMessage.Number); - } - finally - { - rebuilt?.Delete(); - original?.Delete(); - } - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter SpawnerDtoRoundTripTests` -Expected: FAIL — `SpawnerDto`, `ToDto`, `ToSpawner` do not exist. - -- [ ] **Step 3a: Create the DTO records** - -`Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs`: - -```csharp -/************************************************************************* - * ModernUO * - * File: SpawnerDto.cs * - *************************************************************************/ - -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; -using Server.Json; -using Server.Regions; - -namespace Server.Engines.Spawners; - -/// -/// Plain data carrier for spawner JSON. System.Text.Json deserializes into these records -/// (never a live Item), so a malformed file fails as GC only. builds -/// the real spawner from a fully-validated DTO. Sparse fields are nullable so WhenWritingNull -/// omits domain defaults, matching the legacy ToJson output. -/// -public abstract record SpawnerDto -{ - [JsonPropertyName("guid")] public Guid? Guid { get; init; } - [JsonPropertyName("location")] public Point3D Location { get; init; } - [JsonPropertyName("map")] public Map Map { get; init; } - [JsonPropertyName("count")] public int Count { get; init; } - [JsonPropertyName("name")] public string Name { get; init; } - [JsonPropertyName("minDelay")] public TimeSpan? MinDelay { get; init; } - [JsonPropertyName("maxDelay")] public TimeSpan? MaxDelay { get; init; } - [JsonPropertyName("team")] public int? Team { get; init; } - [JsonPropertyName("walkingRange")] public int? WalkingRange { get; init; } - [JsonPropertyName("homeRange")] public int? HomeRange { get; init; } // legacy read; never written - [JsonPropertyName("spawnLocationIsHome")] public bool? SpawnLocationIsHome { get; init; } - [JsonPropertyName("spawnPositionMode")] public SpawnPositionMode? SpawnPositionMode { get; init; } - [JsonPropertyName("maxSpawnAttempts")] public int? MaxSpawnAttempts { get; init; } - [JsonPropertyName("entries")] public List Entries { get; init; } - - /// Constructs the empty concrete spawner Item for this DTO. - protected abstract BaseSpawner CreateEmpty(); - - /// Builds and populates the live spawner. Override to apply subtype fields after base. - public virtual BaseSpawner ToSpawner() - { - var spawner = CreateEmpty(); - spawner.ApplyDto(this); - return spawner; - } -} - -[JsonDiscoverableType("Spawner")] -public sealed record SpawnerDataDto : SpawnerDto -{ - [JsonPropertyName("spawnBounds")] public Rectangle3D? SpawnBounds { get; init; } - - protected override BaseSpawner CreateEmpty() => new Spawner(); - - public override BaseSpawner ToSpawner() - { - var spawner = (Spawner)base.ToSpawner(); - if (SpawnBounds is { } bounds && bounds != default) - { - spawner.SpawnBounds = bounds; - } - - return spawner; - } -} - -[JsonDiscoverableType("RegionSpawner")] -public sealed record RegionSpawnerDto : SpawnerDto -{ - [JsonPropertyName("region")] public string Region { get; init; } - - protected override BaseSpawner CreateEmpty() => new RegionSpawner(); - - public override BaseSpawner ToSpawner() - { - var spawner = (RegionSpawner)base.ToSpawner(); - spawner.SpawnRegion = Server.Regions.Region.Find(Region, Map) as BaseRegion; - return spawner; - } -} - -[JsonDiscoverableType("ProximitySpawner")] -public sealed record ProximitySpawnerDto : SpawnerDto -{ - [JsonPropertyName("spawnBounds")] public Rectangle3D? SpawnBounds { get; init; } - [JsonPropertyName("triggerRange")] public int TriggerRange { get; init; } - [JsonPropertyName("spawnMessage")] public TextDefinition SpawnMessage { get; init; } - [JsonPropertyName("instant")] public bool Instant { get; init; } - - protected override BaseSpawner CreateEmpty() => new ProximitySpawner(); - - public override BaseSpawner ToSpawner() - { - var spawner = (ProximitySpawner)base.ToSpawner(); - if (SpawnBounds is { } bounds && bounds != default) - { - spawner.SpawnBounds = bounds; - } - - spawner.TriggerRange = TriggerRange; - spawner.SpawnMessage = SpawnMessage; - spawner.InstantFlag = Instant; - return spawner; - } -} -``` - -- [ ] **Step 3b: Add `BaseSpawner.Dto.cs` (import population + export getters)** - -```csharp -/************************************************************************* - * ModernUO * - * File: BaseSpawner.Dto.cs * - *************************************************************************/ - -using System; - -namespace Server.Engines.Spawners; - -public abstract partial class BaseSpawner -{ - /// Applies the common DTO fields to this freshly-created spawner (import path). - internal void ApplyDto(SpawnerDto dto) - { - _guid = dto.Guid ?? Guid.NewGuid(); - - if (!string.IsNullOrEmpty(dto.Name)) - { - Name = dto.Name; - } - - // Legacy homeRange -> spawnBounds (Map not available yet; use the DTO location). - if (dto.HomeRange is int homeRange && homeRange >= 0) - { - int z; - int depth; - if (homeRange == 0) - { - z = dto.Location.Z; - depth = 0; - } - else - { - z = -128; - depth = 256; - } - - SpawnBounds = new Rectangle3D( - dto.Location.X - homeRange, - dto.Location.Y - homeRange, - z, - homeRange * 2 + 1, - homeRange * 2 + 1, - depth - ); - } - - InitSpawn(dto.Count, dto.MinDelay ?? DefaultMinDelay, dto.MaxDelay ?? DefaultMaxDelay, dto.Team ?? 0, SpawnBounds); - - _walkingRange = dto.WalkingRange ?? -1; - _spawnLocationIsHome = dto.SpawnLocationIsHome ?? false; - _spawnPositionMode = dto.SpawnPositionMode ?? SpawnPositionMode.Automatic; - _maxSpawnAttempts = dto.MaxSpawnAttempts ?? DefaultMaxSpawnAttempts; - - if (dto.Entries != null) - { - for (var i = 0; i < dto.Entries.Count; i++) - { - var entry = dto.Entries[i]; - AddEntry(entry.SpawnedName, entry.SpawnedProbability, entry.SpawnedMaxCount, false, entry.Properties, entry.Parameters); - } - } - } - - // Export helpers — nullable so WhenWritingNull omits domain defaults, matching legacy ToJson. - private protected Guid? DtoGuid => _guid; - private protected string DtoName => string.IsNullOrEmpty(Name) ? null : Name; - private protected TimeSpan? DtoMinDelay => _minDelay == DefaultMinDelay ? null : _minDelay; - private protected TimeSpan? DtoMaxDelay => _maxDelay == DefaultMaxDelay ? null : _maxDelay; - private protected int? DtoTeam => _team == 0 ? null : _team; - private protected int? DtoWalkingRange => _walkingRange != 0 ? WalkingRange : null; - private protected bool? DtoSpawnLocationIsHome => _spawnLocationIsHome ? true : null; - - private protected SpawnPositionMode? DtoSpawnPositionMode => - _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned - ? _spawnPositionMode - : null; - - // Runtime treats 0 identically to DefaultMaxSpawnAttempts (BaseSpawner.cs maxAttempts clamp), - // so both are omitted — fresh spawners have _maxSpawnAttempts == 0. - private protected int? DtoMaxSpawnAttempts => - _maxSpawnAttempts > 0 && _maxSpawnAttempts != DefaultMaxSpawnAttempts ? _maxSpawnAttempts : null; -} -``` - -- [ ] **Step 3c: Add `BaseSpawner.ToDto()` abstract + per-type overrides** - -In `BaseSpawner.cs`, add the abstract declaration (near `SpawnBounds`): - -```csharp - /// Builds the JSON DTO for this spawner (export path). - public abstract SpawnerDto ToDto(); -``` - -`Projects/UOContent/Engines/Spawners/Spawner.Dto.cs`: - -```csharp -/************************************************************************* - * ModernUO * - * File: Spawner.Dto.cs * - *************************************************************************/ - -namespace Server.Engines.Spawners; - -public partial class Spawner -{ - public override SpawnerDto ToDto() => new SpawnerDataDto - { - Guid = DtoGuid, - Location = Location, - Map = Map, - Count = Count, - Name = DtoName, - MinDelay = DtoMinDelay, - MaxDelay = DtoMaxDelay, - Team = DtoTeam, - WalkingRange = DtoWalkingRange, - SpawnLocationIsHome = DtoSpawnLocationIsHome, - SpawnPositionMode = DtoSpawnPositionMode, - MaxSpawnAttempts = DtoMaxSpawnAttempts, - Entries = Entries, - SpawnBounds = SpawnBounds == default ? null : SpawnBounds - }; -} -``` - -`RegionSpawner.Dto.cs`: - -```csharp -/************************************************************************* - * ModernUO * - * File: RegionSpawner.Dto.cs * - *************************************************************************/ - -namespace Server.Engines.Spawners; - -public partial class RegionSpawner -{ - public override SpawnerDto ToDto() => new RegionSpawnerDto - { - Guid = DtoGuid, - Location = Location, - Map = Map, - Count = Count, - Name = DtoName, - MinDelay = DtoMinDelay, - MaxDelay = DtoMaxDelay, - Team = DtoTeam, - WalkingRange = DtoWalkingRange, - SpawnLocationIsHome = DtoSpawnLocationIsHome, - SpawnPositionMode = DtoSpawnPositionMode, - MaxSpawnAttempts = DtoMaxSpawnAttempts, - Entries = Entries, - Region = SpawnRegion?.Name - }; -} -``` - -`ProximitySpawner.Dto.cs`: - -```csharp -/************************************************************************* - * ModernUO * - * File: ProximitySpawner.Dto.cs * - *************************************************************************/ - -namespace Server.Engines.Spawners; - -public partial class ProximitySpawner -{ - public override SpawnerDto ToDto() => new ProximitySpawnerDto - { - Guid = DtoGuid, - Location = Location, - Map = Map, - Count = Count, - Name = DtoName, - MinDelay = DtoMinDelay, - MaxDelay = DtoMaxDelay, - Team = DtoTeam, - WalkingRange = DtoWalkingRange, - SpawnLocationIsHome = DtoSpawnLocationIsHome, - SpawnPositionMode = DtoSpawnPositionMode, - MaxSpawnAttempts = DtoMaxSpawnAttempts, - Entries = Entries, - SpawnBounds = SpawnBounds == default ? null : SpawnBounds, - TriggerRange = TriggerRange, - SpawnMessage = SpawnMessage, - Instant = InstantFlag - }; -} -``` - -- [ ] **Step 3d: Retarget + simplify `SpawnerJsonSerializer`** - -Edit `SpawnerJsonSerializer.cs`: change the discovery filter from `typeof(BaseSpawner)` to `typeof(SpawnerDto)` (in `Collect`/`Validate`), change `AddPolymorphism` to gate on `typeInfo.Type == typeof(SpawnerDto)`, and **remove** the `PruneToJsonProperties` and `AddOnDeserialized` modifiers (and their entries in the `Modifiers` list). Result — `Options` resolver has a single modifier (polymorphism); `Configure`/`Collect`/`Validate`/`IsJsonConstructible` otherwise unchanged but operating on `SpawnerDto` subtypes. Keep the `TextDefinitionConverterFactory` registration (for `ProximitySpawnerDto.SpawnMessage`). - -- [ ] **Step 3e: Remove the Approach-A artifacts** - -```bash -git rm Projects/UOContent/Engines/Spawners/BaseSpawner.Json.cs \ - Projects/UOContent/Engines/Spawners/Spawner.Json.cs \ - Projects/UOContent/Engines/Spawners/RegionSpawner.Json.cs \ - Projects/UOContent/Engines/Spawners/ProximitySpawner.Json.cs \ - Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerRoundTripTests.cs \ - Projects/UOContent.Tests/Tests/Engines/Spawners/Json/RegionSpawnerRoundTripTests.cs \ - Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ProximitySpawnerRoundTripTests.cs -``` - -In `Spawner.cs`, `RegionSpawner.cs`, `ProximitySpawner.cs`: remove the `[JsonDiscoverableType]` class attribute and the `[JsonConstructor]` on the parameterless ctor that Approach A added (leave `[Constructible]`). Remove now-unused `using Server.Json;`/`using System.Text.Json;` if the compiler flags them. - -- [ ] **Step 4: Build, then run the tests** - -Run: `dotnet build ModernUO.sln` then `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter SpawnerDtoRoundTripTests` -Expected: build SUCCESS; 4/4 pass. If `$type` is missing, confirm the list element type is `SpawnerDto` and the discriminator override strings match. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "refactor(spawners): deserialize via SpawnerDto records instead of live Items" -``` - ---- - -## Task 3: Legacy homeRange read regression (DTO) - -**Files:** -- Create: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/LegacyHomeRangeTests.cs` (replaces the A version if it still exists — `git rm` it first if present) - -- [ ] **Step 1: Write the 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 -{ - [Theory] - [InlineData(3, 97, 197, -128, 7, 7, 256)] - [InlineData(0, 100, 200, 5, 1, 1, 0)] - public void HomeRange_ConvertsToCenteredBounds(int homeRange, int x, int y, int z, int w, int h, int d) - { - var json = $$""" - [ { "$type": "Spawner", "location": [100, 200, 5], "map": "Felucca", "count": 1, - "homeRange": {{homeRange}}, - "entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ] } ] - """; - - BaseSpawner s = null; - try - { - var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); - s = Assert.Single(dtos).ToSpawner(); - Assert.Equal(new Rectangle3D(x, y, z, w, h, d), s.SpawnBounds); - } - finally - { - s?.Delete(); - } - } -} -``` - -- [ ] **Step 2: Run (implementation exists from Task 2)** - -Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter LegacyHomeRangeTests` -Expected: PASS (2 cases). If FAIL, fix `BaseSpawner.ApplyDto`'s homeRange block, not the test. - -- [ ] **Step 3: Commit** - -```bash -git add -A -git commit -m "test(spawners): lock legacy homeRange->spawnBounds via DTO" -``` - ---- - -## Task 4: Rewire `ExportSpawnersCommand` to DTOs - -**Files:** -- Modify: `Projects/UOContent/Engines/Spawners/Commands/ExportSpawnersCommand.cs` -- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ExportImportFileTests.cs` (replace the A version — `git rm` it first) - -- [ ] **Step 1: Edit the export loop** - -Build a `List` by calling `ToDto()` (preserve the existing `Map.Internal`/`Parent`/name-prefix selection guards), then `JsonConfig.Serialize(path, spawnRecords, SpawnerJsonSerializer.Options)`: - -```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; - } - - // (preserve any existing name-prefix filter here) - spawnRecords.Add(spawner.ToDto()); - } - - if (spawnRecords.Count == 0) - { - LogFailure("No matching spawners found."); - return; - } - - e.Mobile.SendMessage("Exporting spawners..."); - JsonConfig.Serialize(path, spawnRecords, SpawnerJsonSerializer.Options); -``` - -Remove any leftover `DynamicJson`/`ToJson` calls and unused `options` locals. Keep `using Server.Json;`. - -- [ ] **Step 2: Build** - -Run: `dotnet build Projects/UOContent/UOContent.csproj` → SUCCESS. - -- [ ] **Step 3: Write the file round-trip test** - -```csharp -using System.Collections.Generic; -using System.IO; -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() - { - Spawner original = null; - BaseSpawner rebuilt = null; - var path = Path.GetTempFileName(); - try - { - original = new Spawner(3, System.TimeSpan.FromMinutes(4), System.TimeSpan.FromMinutes(8), 1, - new Rectangle3D(200, 200, 0, 9, 9, 0), "Tanner"); - original.MoveToWorld(new Point3D(204, 204, 0), Map.Felucca); - - JsonConfig.Serialize(path, new List { original.ToDto() }, SpawnerJsonSerializer.Options); - - var dtos = JsonConfig.Deserialize>(path, SpawnerJsonSerializer.Options); - rebuilt = Assert.Single(dtos).ToSpawner(); - var s = Assert.IsType(rebuilt); - Assert.Equal(3, s.Count); - Assert.Equal(1, s.Team); - Assert.Equal(new Rectangle3D(200, 200, 0, 9, 9, 0), s.SpawnBounds); - } - finally - { - rebuilt?.Delete(); - original?.Delete(); - File.Delete(path); - } - } -} -``` - -- [ ] **Step 4: Run** - -Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter ExportImportFileTests` → PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "refactor(spawners): export via SpawnerDto" -``` - ---- - -## Task 5: Rewire `ImportSpawnersCommand` to DTOs (leak-free) - -**Files:** -- Modify: `Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs` -- Test: `Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs` (replace the A version) - -**Interfaces:** -- Consumes: `SpawnerDto.ToSpawner()`, `SpawnerDto.Location`/`.Map`, `SpawnerJsonSerializer.Options`. - -- [ ] **Step 1: Rewrite `ImportJsonSpawners`** - -```csharp - private static void ImportJsonSpawners( - Mobile from, - FileInfo file, - Dictionary allSpawners, - ref int totalGenerated, - ref int totalFailures - ) - { - List dtos; - try - { - // DTO deserialization constructs NO world objects — a malformed file fails as GC only. - dtos = 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 (dtos == null || dtos.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 < dtos.Count; i++) - { - var dto = dtos[i]; - var location = dto.Location; - var map = dto.Map; - - if (map == null || map == Map.Internal) - { - logger.Error("Spawner {Guid} ({Index}) has no valid map; skipping.", dto.Guid, i); - totalFailures++; - continue; - } - - BaseSpawner spawner; - try - { - spawner = dto.ToSpawner(); // constructs the single Item, now referenced - } - catch (Exception ex) - { - TraceException(ex, $"Failed to build spawner {dto.Guid}."); - totalFailures++; - continue; - } - - var type = spawner.GetType(); - 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 now-unused `using` for `AssemblyHandler`/`System.Reflection` if flagged. Keep `using Server.Json;`. If a test seam is needed (no public file entry), add a minimal `internal static void ImportFile(FileInfo, Dictionary)` wrapping this; verify `[assembly: InternalsVisibleTo("UOContent.Tests")]` exists. - -- [ ] **Step 2: Build** → `dotnet build ModernUO.sln` SUCCESS. - -- [ ] **Step 3: Tests — placement + malformed-no-leak** - -```csharp -using System; -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 guid = new Guid("11111111-1111-1111-1111-111111111111"); - var dir = Path.Combine(Path.GetTempPath(), "muo-import-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, "t.json"), $$""" - [ { "$type": "Spawner", "guid": "{{guid}}", "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.ImportFile(new FileInfo(Path.Combine(dir, "t.json")), new Dictionary()); - var found = false; - foreach (var s in Map.Felucca.GetItemsAt(new Point3D(305, 305, 0))) - { - if (s.Guid == guid) { found = true; s.Delete(); } - } - Assert.True(found); - } - finally - { - Directory.Delete(dir, true); - } - } - - [Fact] - public void Import_MalformedFile_LeaksNoWorldItems() - { - var dir = Path.Combine(Path.GetTempPath(), "muo-import-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - // First entry valid, then truncated/garbage — STJ throws mid-array. - File.WriteAllText(Path.Combine(dir, "bad.json"), """ - [ { "$type": "Spawner", "location": [320, 320, 0], "map": "Felucca", "count": 1, - "entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ] }, - { "$type": "Spawner", "location": [ THIS IS NOT JSON - """); - try - { - var before = CountSpawnersNear(new Point3D(320, 320, 0)); - ImportSpawnersCommand.ImportFile(new FileInfo(Path.Combine(dir, "bad.json")), new Dictionary()); - // Malformed parse must construct zero Items (DTO is GC-only); nothing placed or orphaned. - Assert.Equal(before, CountSpawnersNear(new Point3D(320, 320, 0))); - } - finally - { - Directory.Delete(dir, true); - } - } - - private static int CountSpawnersNear(Point3D p) - { - var n = 0; - foreach (var _ in Map.Felucca.GetItemsAt(p)) { n++; } - return n; - } -} -``` - -> If `Map.GetItemsAt` is not the right API for an Internal-orphan check, the malformed test's intent is: after importing a file that throws mid-array, no new `BaseSpawner` exists at the valid entry's location (320,320,0) — because DTO parse builds no Items. Adapt the query to whatever the codebase exposes; the assertion is "zero spawners constructed from a malformed file." - -- [ ] **Step 4: Run** - -Run: `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj --filter ImportCleanupTests` → PASS (both). Then full suite once. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "refactor(spawners): import via SpawnerDto (leak-free on malformed input)" -``` - ---- - -## Task 6: One-time data migration to `$type` - -Identical to v1 plan Task 8 (the data format is independent of A vs B). Run the `tools/spawner-json-migrate/migrate.mjs` transform (rename `type`→`$type` first-key; `homeRange`+`location`→`spawnBounds`), validate, commit data, remove the tool. **Adapt the verification test** to deserialize `List` (not `List`) and call `ToSpawner()` on each, cleaning up in `finally`. Use the migration script and steps from v1 Task 8 verbatim except the test's deserialize target. - -- [ ] Steps: see v1 plan Task 8, Steps 1–6, with the test changed to `JsonSerializer.Deserialize>(...)` + `dto.ToSpawner()` + `?.Delete()`. - ---- - -## Task 7: Delete `DynamicJson` + old spawner JSON members - -**Files:** -- Delete: `Projects/Server/Json/DynamicJson.cs` -- Modify: `BaseSpawner.cs`, `Spawner.cs`, `RegionSpawner.cs`, `ProximitySpawner.cs` (remove `(DynamicJson, options)` ctors + `ToJson` methods) - -- [ ] **Step 1:** Remove every `(DynamicJson, options)` constructor and `ToJson(DynamicJson, ...)` method from the four spawner classes. Remove unused `using Server.Json;`/`using System.Text.Json;`. -- [ ] **Step 2:** `git rm Projects/Server/Json/DynamicJson.cs` -- [ ] **Step 3:** `git grep -n "DynamicJson"` → no matches. -- [ ] **Step 4:** `dotnet build ModernUO.sln` → SUCCESS. -- [ ] **Step 5:** Commit: `refactor(json): delete DynamicJson; spawners use SpawnerDto exclusively` - ---- - -## Task 8: Load-all regression over `Spawns/` - -Identical to v1 Task 10, with the deserialize target `List` and a `ToSpawner()` build per entry (cleaned up in `finally`): - -- [ ] **Step 1:** Test `AllSpawnFilesLoadTests`: for every `Data/Spawns/**/*.json`, `JsonSerializer.Deserialize>(...)`; for each DTO call `ToSpawner()` then `Delete()`; collect any `JsonException` into a failures list; `Assert.True(failures.Count == 0, join)`. Guard `if (!Directory.Exists(root)) return;`. -- [ ] **Step 2:** Run → PASS. -- [ ] **Step 3:** Commit: `test(spawners): assert all migrated spawn files build via DTO` - ---- - -## Task 9: Discovery validation tests - -As v1 Task 11 but against `SpawnerDto` subtypes. Refactor `SpawnerJsonSerializer.Collect` to delegate to `internal static (string, JsonDerivedType) Validate(Type, Dictionary)`; test duplicate-discriminator detection with two nested `[JsonDiscoverableType("dup")]` `SpawnerDto` records. - -- [ ] **Step 1:** Expose `Validate` (internal). Confirm `InternalsVisibleTo("UOContent.Tests")`. -- [ ] **Step 2:** Test `DuplicateDiscriminator_Throws`: two `record DupA/DupB : SpawnerDto` with `[JsonDiscoverableType("dup")]` and a trivial `CreateEmpty()` (e.g. `=> new Spawner()`); first `Validate` registers, second throws containing `"dup"`. -- [ ] **Step 3:** Run → PASS. -- [ ] **Step 4:** Commit: `test(spawners): validate duplicate SpawnerDto discriminator detection` - ---- - -## Task 10: Full suite + spec sync + final review - -- [ ] **Step 1:** `dotnet test Projects/UOContent.Tests/UOContent.Tests.csproj` and `dotnet test Projects/Server.Tests/Server.Tests.csproj` → all PASS. -- [ ] **Step 2:** Confirm the design spec §0 Revision matches what shipped; fix any drift. -- [ ] **Step 3:** Commit any doc edits. Then the controller runs the final whole-branch review. - ---- - -## Self-Review - -**Spec coverage (Approach B):** -- Delete DynamicJson → Task 7. ✓ -- DTO records, no live-Item deserialize → Task 2. ✓ -- Exact sparse output via nullable DTO props + WhenWritingNull → Task 2 (`Dto*` getters) + omission test. ✓ -- Auto-discovery, no registration; reusable marker → Task 1 (kept) + Task 2 retarget to `SpawnerDto`. ✓ -- `$type` migration → Task 6. ✓ -- Legacy homeRange read → Task 2 (`ApplyDto`) + Task 3 test. ✓ -- Leak-free import (the whole reason for B) → Task 5 + malformed-no-leak test. ✓ -- Collision/constructibility validation → Task 2 (`Collect`) + Task 9. ✓ -- Open extensibility (custom spawner = custom DTO + `ToDto` override) → DTO design. ✓ - -**Placeholder scan:** No TBD/"handle edge cases". The Task 5 malformed-test API caveat and Task 4 name-filter note give explicit decision rules. - -**Type consistency:** `SpawnerDto.ToSpawner()`, `BaseSpawner.ToDto()`/`ApplyDto()`, `Dto*` getters, `SpawnerJsonSerializer.Options/Configure/Validate` consistent across tasks. DTO discriminator overrides (`"Spawner"`/`"RegionSpawner"`/`"ProximitySpawner"`) match the migrated data and the round-trip assertions. - -**Risk to watch during execution:** record init-property deserialization with the custom converters (Point3D/Map/Rectangle3D/TextDefinition) — confirm they bind through `[JsonPropertyName]` init props (Task 2 round-trip test is the gate). `Region.Find` in `RegionSpawnerDto.ToSpawner` needs the region registered (test registers one). `InitSpawn` is called once in `ApplyDto`; derived `ToSpawner` overrides set subtype fields AFTER base — matches legacy ordering. diff --git a/docs/superpowers/plans/2026-06-25-spawner-stj-migration.md b/docs/superpowers/plans/2026-06-25-spawner-stj-migration.md deleted file mode 100644 index 952e5652d..000000000 --- a/docs/superpowers/plans/2026-06-25-spawner-stj-migration.md +++ /dev/null @@ -1,1773 +0,0 @@ -# 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 deleted file mode 100644 index 932e4d696..000000000 --- a/docs/superpowers/specs/2026-06-25-spawner-stj-migration-design.md +++ /dev/null @@ -1,316 +0,0 @@ -# Remove `DynamicJson`, migrate spawners to typed System.Text.Json - -- **Date:** 2026-06-25 -- **Status:** Approved (design); **revised mid-implementation — see §0 Revision** -- **Author:** Kamron Batman (with Claude) -- **Scope:** `Projects/Server/Json/`, `Projects/UOContent/Engines/Spawners/`, `Distribution/Data/Spawns/` - -## 0. Revision (2026-06-25): Approach A → B (DTO records) - -The original design (§5 below) made each live spawner `Item` the STJ deserialization -target via inert shadow properties + an `OnAfterJsonDeserialize` hook ("Approach A"). -During implementation (after Tasks 1–7 of the v1 plan) we hit a structural flaw: - -> **STJ constructs each spawner `Item` — registered in `World` at `Map.Internal` — as it -> streams the JSON, *before* the data is validated.** A `JsonException` mid-array (or any -> converter / post-construct failure) leaves already-constructed spawner Items orphaned in -> the world with no reference to delete them. On a malformed/hand-edited spawn file this is -> a **world-save leak**, and it cannot be fully closed while the `Item` is the deserialization -> target (even per-element deserialization constructs the Item before a property failure). - -**Resolution — Approach B:** deserialize to a **plain `record` DTO** (no `Item`, no world -registration), validate by virtue of a successful parse, then construct the real spawner via -a factory. A parse failure is now pure GC — zero world state touched. This supersedes §5's -"shadow property" mechanism. Decisions D1–D5 stand; the `$type` discriminator, auto-discovery, -the `[JsonDiscoverableType]` marker, exact sparse output (now via nullable DTO properties + -`WhenWritingNull`), legacy `homeRange` read, and the data migration are all unchanged. Only -the *binding mechanism* changes. Note: the regions #1400 "DTOs are pure overhead" lesson does -**not** transfer — a `Region` is itself a POCO, whereas a spawner is an `Item` with a world -lifecycle, so the DTO is what keeps STJ away from world state rather than redundant ceremony. - -### 0.1 Approach B architecture (authoritative; supersedes §5.1–5.4) - -**Polymorphic DTO hierarchy** (`Projects/UOContent/Engines/Spawners/Json/`), one DTO per -spawner type, each carrying the `[JsonDiscoverableType]` marker: - -- `abstract record SpawnerDto` — the common `BaseSpawner` JSON fields (guid, location, map, - count, name, minDelay, maxDelay, team, walkingRange, **homeRange** [legacy read], - spawnLocationIsHome, spawnPositionMode, maxSpawnAttempts, entries). Sparse fields are - nullable (`int?`, `TimeSpan?`, …) so `WhenWritingNull` omits domain-defaults, matching - today's `ToJson` exactly. Declares `abstract BaseSpawner CreateEmpty()` (constructs the - right empty `Item`) and a concrete `BaseSpawner ToSpawner()` that calls `CreateEmpty()`, - applies the common fields (the former `OnAfterJsonDeserialize` body: `InitSpawn`, entries, - `homeRange`→`spawnBounds`), and returns the live spawner. -- `record SpawnerDataDto : SpawnerDto` (`$type` = `"Spawner"`) — adds `spawnBounds`. -- `record RegionSpawnerDto : SpawnerDto` (`$type` = `"RegionSpawner"`) — adds `region` - (resolved against the DTO's `map` in `ToSpawner`). -- `record ProximitySpawnerDto : SpawnerDto` (`$type` = `"ProximitySpawner"`) — adds - `spawnBounds`, `triggerRange`, `spawnMessage`, `instant`. - -Each concrete DTO overrides `CreateEmpty()` and extends `ToSpawner()` to apply its extra -fields (calling base first). - -**Symmetric export mapping** lives on the spawner as `virtual SpawnerDto BaseSpawner.ToDto()` -(each concrete spawner overrides it). This keeps extensibility open and symmetric: a custom -spawner type ships a paired DTO (`[JsonDiscoverableType]` + `ToSpawner`) and overrides -`ToDto()` — no central registry, no closed switch. Export builds a `List` by -calling `ToDto()` on each spawner; STJ writes `$type` via polymorphism. Import deserializes -`List` then calls `ToSpawner()` per element. - -**`SpawnerJsonSerializer` simplifies.** It now discovers `[JsonDiscoverableType]` types -assignable to **`SpawnerDto`** (not `BaseSpawner`) and wires STJ polymorphism on the -`SpawnerDto` root. The **property-pruning resolver modifier and the `OnDeserialized` hook -are removed** — a DTO record exposes only its declared JSON properties, and `ToSpawner()` -is invoked explicitly by the importer rather than by STJ. Discovery, collision/constructibility -validation, and the discriminator rules are unchanged. - -**Import cleanup contract becomes trivial and complete.** DTO deserialization never -constructs an `Item`; a malformed file fails as GC-only. `ToSpawner()` constructs exactly -one `Item` per validated DTO, under our control: the importer places it (`MoveToWorld` + -`Respawn`) or, on a `ToSpawner`/placement failure, `Delete()`s the single referenced spawner. -No mid-array orphan is possible. - -The remainder of §5 (data migration §5.5, deletion §5.6) is unchanged. §6–§11 stand, with -"shadow property" read as "DTO property" and `OnAfterJsonDeserialize` read as `ToSpawner()`. - -## 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.