Editor/Data/ArchUnitConverter.cs

A custom System.Text.Json converter for ArchUnit that reads a JSON unit object, resolves its concrete type by a Kind discriminator (handling legacy "$kind" lowercase values), and deserializes into the claimed type or into an ArchOpaqueUnit when unknown. On write it ensures in-memory units get a claimed Kind if possible and then serializes the concrete runtime type.

Reflection
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Sandbox;

namespace Sunless.Architecture;

// Reads a top-level unit into whatever type claims its kind, and into a bare carrier when nothing does.
//
// [JsonPolymorphic] cannot do this job: JsonDerivedType is an attribute on the BASE type, so core would have to
// name every module's unit type, and System.Text.Json throws on a discriminator it does not recognise with no
// read-side equivalent of JsonUnknownDerivedTypeHandling. After the split an unrecognised kind is the ordinary
// not-installed case, and throwing there means the plan reads null and the next commit writes an empty one over it.
public sealed class ArchUnitConverter : JsonConverter<ArchUnit>
{
	// Only the base: asking for a subclass must deserialize normally or this converter calls itself forever.
	public override bool CanConvert( Type type ) => type == typeof( ArchUnit );

	public override ArchUnit Read( ref Utf8JsonReader reader, Type type, JsonSerializerOptions options )
	{
		using var document = JsonDocument.ParseValue( ref reader );
		var element = document.RootElement;

		var claimed = ArchKinds.Load().For( KindOf( element ) );
		var resolved = claimed?.Payload;

		if ( resolved is null || !typeof( ArchUnit ).IsAssignableFrom( resolved ) )
		{
			return Opaque( element, options );
		}

		return JsonSerializer.Deserialize( element.GetRawText(), resolved, options ) as ArchUnit;
	}

	public override void Write( Utf8JsonWriter writer, ArchUnit value, JsonSerializerOptions options )
	{
		// A unit built in memory by something that forgot to say what it is would otherwise go to disk unnamed and
		// read back opaque, which loses it on the round trip it was meant to survive.
		if ( !value.Kind.Known && ArchKinds.Load().Claiming( value.GetType() ) is { } manifest )
		{
			value.Kind = manifest.Kind;
		}

		JsonSerializer.Serialize( writer, value, value.GetType(), options );
	}

	// Version 3 wrote a lower-case polymorphic discriminator; every kind elsewhere in the document is the
	// capitalised ident, so the two spellings meet here rather than anywhere a module can see.
	static ArchKind KindOf( JsonElement element )
	{
		if ( element.TryGetProperty( "Kind", out var named ) && named.ValueKind == JsonValueKind.String )
		{
			return new ArchKind( named.GetString() ?? "" );
		}

		if ( !element.TryGetProperty( "$kind", out var legacy ) || legacy.ValueKind != JsonValueKind.String )
		{
			return ArchKind.None;
		}

		return legacy.GetString() switch
		{
			"building" => ArchKind.Building,
			"road" => ArchKind.Road,
			var other => new ArchKind( other ?? "" )
		};
	}

	static ArchUnit Opaque( JsonElement element, JsonSerializerOptions options )
	{
		var carrier = JsonSerializer.Deserialize<ArchOpaqueUnit>( element.GetRawText(), options ) ?? new ArchOpaqueUnit();

		carrier.Kind = KindOf( element );

		return carrier;
	}
}