Code/V2/Networking/SnapshotWireCodec.cs

JSON wire codec for immutable presentation snapshots. Encodes and decodes CharacterList, ClientState and Chat snapshots to/from versioned JSON envelopes, validates JSON graph shape and size limits, and maps between domain snapshot types and private wire DTOs with defensive checks.

File AccessNetworkingHttp Calls
#nullable enable

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;

namespace Hexagon.V2.Networking;

/// <summary>
/// Versioned JSON-over-bytes transport for immutable presentation snapshots.
/// Mutable wire objects are private and every decode reconstructs the public
/// snapshot graph through its validating, defensive-copying constructors.
/// </summary>
public static class SnapshotWireCodec
{
	public const int CurrentWireVersion = 1;
	public const int MaximumPayloadBytes = 1024 * 1024;
	public const int MaximumJsonDepth = 32;

	private const int MaximumGraphNodes = 65_536;
	private const int MaximumArrayEntries = 8_192;
	private const int MaximumMapEntries = 512;
	private const int MaximumStringCharacters = 65_536;
	private const int MaximumCharacters = 1_024;
	private const int MaximumChatMessages = 4_096;
	private const int MaximumRosterRows = 1_024;
	private const int MaximumSchemaViews = 256;
	private const int MaximumSchemaRows = 4_096;
	private const int MaximumInventories = 128;
	private const int MaximumInventoryItems = 4_096;
	private const int MaximumItemActions = 256;

	private const string CharacterListKind = "character-list";
	private const string ClientStateKind = "client-state";
	private const string ChatKind = "chat";

	private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();

	public static OperationResult<byte[]> EncodeCharacterList( CharacterListSnapshot snapshot ) =>
		Encode( CharacterListKind, snapshot, ToWire, FromWire );

	public static OperationResult<CharacterListSnapshot> DecodeCharacterList( byte[] payload ) =>
		Decode<CharacterListWire, CharacterListSnapshot>( payload, CharacterListKind, FromWire );

	public static OperationResult<byte[]> EncodeClientState( ClientStateSnapshot snapshot ) =>
		Encode( ClientStateKind, snapshot, ToWire, FromWire );

	public static OperationResult<ClientStateSnapshot> DecodeClientState( byte[] payload ) =>
		Decode<ClientStateWire, ClientStateSnapshot>( payload, ClientStateKind, FromWire );

	public static OperationResult<byte[]> EncodeChat( ChatSnapshot snapshot ) =>
		Encode( ChatKind, snapshot, ToWire, FromWire );

	public static OperationResult<ChatSnapshot> DecodeChat( byte[] payload ) =>
		Decode<ChatWire, ChatSnapshot>( payload, ChatKind, FromWire );

	private static OperationResult<byte[]> Encode<TSnapshot, TWire>(
		string kind,
		TSnapshot snapshot,
		Func<TSnapshot, TWire> convert,
		Func<TWire, TSnapshot> validate )
		where TSnapshot : class
		where TWire : class
	{
		if ( snapshot is null )
			return Failure<byte[]>( "A snapshot is required." );

		try
		{
			var wire = convert( snapshot );
			_ = validate( wire );
			var envelope = new WireEnvelope<TWire>
			{
				Version = CurrentWireVersion,
				Kind = kind,
				Payload = wire
			};
			var encoded = JsonSerializer.SerializeToUtf8Bytes( envelope, JsonOptions );
			if ( encoded.Length > MaximumPayloadBytes )
				return Failure<byte[]>( $"Snapshot wire payload exceeds {MaximumPayloadBytes} bytes." );
			// Keep encode/decode acceptance symmetric. This also rejects invalid
			// Unicode and graph limits before bytes can enter an RPC.
			ValidateJsonGraph( encoded );
			return OperationResult<byte[]>.Success( encoded );
		}
		catch ( Exception exception ) when ( IsCodecFailure( exception ) )
		{
			return Failure<byte[]>( "Snapshot cannot be represented by the wire format." );
		}
	}

	private static OperationResult<TSnapshot> Decode<TWire, TSnapshot>(
		byte[] payload,
		string expectedKind,
		Func<TWire, TSnapshot> convert )
		where TWire : class
		where TSnapshot : class
	{
		if ( payload is null || payload.Length == 0 )
			return Failure<TSnapshot>( "A non-empty snapshot wire payload is required." );
		if ( payload.Length > MaximumPayloadBytes )
			return Failure<TSnapshot>( $"Snapshot wire payload exceeds {MaximumPayloadBytes} bytes." );

		try
		{
			ValidateJsonGraph( payload );
			var envelope = JsonSerializer.Deserialize<WireEnvelope<TWire>>( payload, JsonOptions )
				?? throw new JsonException( "Snapshot wire envelope is null." );
			if ( envelope.Version != CurrentWireVersion )
				throw new JsonException( "Unsupported snapshot wire version." );
			if ( !string.Equals( envelope.Kind, expectedKind, StringComparison.Ordinal ) )
				throw new JsonException( "Snapshot wire kind does not match the requested contract." );
			if ( envelope.Payload is null )
				throw new JsonException( "Snapshot wire payload is null." );
			return OperationResult<TSnapshot>.Success( convert( envelope.Payload ) );
		}
		catch ( Exception exception ) when ( IsCodecFailure( exception ) )
		{
			return Failure<TSnapshot>( "Snapshot wire payload is malformed or violates its contract." );
		}
	}

	private static bool IsCodecFailure( Exception exception ) =>
		exception is JsonException or NotSupportedException or ArgumentException or InvalidOperationException;

	private static OperationResult<T> Failure<T>( string message ) =>
		OperationResult<T>.Failure( ErrorCode.InvalidArgument, message );

	private static JsonSerializerOptions CreateJsonOptions()
	{
		var options = new JsonSerializerOptions
		{
			PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
			PropertyNameCaseInsensitive = false,
			NumberHandling = JsonNumberHandling.Strict,
			MaxDepth = MaximumJsonDepth,
			WriteIndented = false
		};
		options.Converters.Add( new SnapshotValueJsonConverter() );
		return options;
	}

	private static void ValidateJsonGraph( byte[] payload )
	{
		using var document = JsonDocument.Parse( payload, new JsonDocumentOptions
		{
			AllowTrailingCommas = false,
			CommentHandling = JsonCommentHandling.Disallow,
			MaxDepth = MaximumJsonDepth
		} );
		var nodes = 0;
		ValidateJsonElement( document.RootElement, ref nodes );
	}

	private static void ValidateJsonElement( JsonElement element, ref int nodes )
	{
		if ( nodes >= MaximumGraphNodes ) throw new JsonException( "Snapshot wire graph is too large." );
		nodes++;

		switch ( element.ValueKind )
		{
			case JsonValueKind.Object:
			{
				var names = new HashSet<string>( StringComparer.Ordinal );
				var properties = 0;
				foreach ( var property in element.EnumerateObject() )
				{
					properties++;
					if ( properties > MaximumMapEntries ) throw new JsonException( "Snapshot wire object is too large." );
					if ( !names.Add( property.Name ) ) throw new JsonException( "Snapshot wire object has duplicate properties." );
					RequireWireString( property.Name, "JSON property name" );
					ValidateJsonElement( property.Value, ref nodes );
				}
				break;
			}
			case JsonValueKind.Array:
			{
				var entries = 0;
				foreach ( var child in element.EnumerateArray() )
				{
					entries++;
					if ( entries > MaximumArrayEntries ) throw new JsonException( "Snapshot wire array is too large." );
					ValidateJsonElement( child, ref nodes );
				}
				break;
			}
			case JsonValueKind.String:
				RequireWireString( element.GetString(), "JSON string" );
				break;
		}
	}

	private static string RequireWireString( string? value, string name )
	{
		if ( value is null ) throw new JsonException( $"{name} cannot be null." );
		if ( value.Length > MaximumStringCharacters ) throw new JsonException( $"{name} is too long." );
		for ( var index = 0; index < value.Length; index++ )
		{
			var character = value[index];
			if ( char.IsHighSurrogate( character ) )
			{
				if ( index + 1 >= value.Length || !char.IsLowSurrogate( value[index + 1] ) )
					throw new JsonException( $"{name} contains invalid Unicode." );
				index++;
			}
			else if ( char.IsLowSurrogate( character ) )
			{
				throw new JsonException( $"{name} contains invalid Unicode." );
			}
		}
		return value;
	}

	private static List<T> RequireList<T>( List<T>? values, int maximum, string name ) where T : class
	{
		if ( values is null ) throw new JsonException( $"{name} is required." );
		if ( values.Count > maximum ) throw new JsonException( $"{name} has too many entries." );
		if ( values.Any( value => value is null ) ) throw new JsonException( $"{name} contains null entries." );
		return values;
	}

	private static Dictionary<string, SnapshotValue> RequireMap(
		Dictionary<string, SnapshotValue>? values,
		string name )
	{
		if ( values is null ) throw new JsonException( $"{name} is required." );
		if ( values.Count > MaximumMapEntries ) throw new JsonException( $"{name} has too many entries." );
		return values;
	}

	private static Dictionary<string, SnapshotValue> CopyMap( IReadOnlyDictionary<string, SnapshotValue> values ) =>
		values.Count > MaximumMapEntries
			? throw new JsonException( "Snapshot value map has too many entries." )
			: values.OrderBy( pair => pair.Key, StringComparer.Ordinal )
				.ToDictionary( pair => pair.Key, pair => pair.Value, StringComparer.Ordinal );

	private static void RequireCount( int count, int maximum, string name )
	{
		if ( count > maximum ) throw new JsonException( $"{name} has too many entries." );
	}

	private static CharacterListWire ToWire( CharacterListSnapshot snapshot )
	{
		RequireCount( snapshot.Characters.Count, MaximumCharacters, "characters" );
		return new CharacterListWire
		{
			Revision = snapshot.Revision,
			Characters = snapshot.Characters.Select( character => new CharacterWire
			{
				CharacterId = character.CharacterId.Value,
				Slot = character.Slot,
				Name = character.Name,
				Description = character.Description,
				Model = character.Model.Value,
				Faction = character.Faction.Value,
				Class = character.Class?.Value,
				LastPlayedAt = character.LastPlayedAt,
				IsBanned = character.IsBanned
			} ).ToList()
		};
	}

	private static CharacterListSnapshot FromWire( CharacterListWire wire )
	{
		var input = RequireList( wire.Characters, MaximumCharacters, "characters" );
		var characters = new List<CharacterSummarySnapshot>( input.Count );
		foreach ( var character in input )
		{
			characters.Add( new CharacterSummarySnapshot(
				new CharacterId( character.CharacterId ),
				character.Slot,
				RequireWireString( character.Name, "character name" ),
				RequireWireString( character.Description, "character description" ),
				new DefinitionId( RequireWireString( character.Model, "character model" ) ),
				new FactionId( RequireWireString( character.Faction, "character faction" ) ),
				character.Class is null ? null : new ClassId( RequireWireString( character.Class, "character class" ) ),
				character.LastPlayedAt,
				character.IsBanned ) );
		}
		return new CharacterListSnapshot( wire.Revision, characters );
	}

	private static ChatWire ToWire( ChatSnapshot snapshot )
	{
		RequireCount( snapshot.Messages.Count, MaximumChatMessages, "chat messages" );
		return new ChatWire
		{
			Connection = snapshot.Epoch.Connection.Value,
			Character = snapshot.Epoch.Character,
			Revision = snapshot.Revision,
			Messages = snapshot.Messages.Select( message => new ChatMessageWire
			{
				MessageId = message.MessageId,
				ChannelId = message.ChannelId,
				AuthorCharacterId = message.AuthorCharacterId?.Value,
				AuthorName = message.AuthorName,
				Text = message.Text,
				SentAtUtc = message.SentAtUtc
			} ).ToList()
		};
	}

	private static ChatSnapshot FromWire( ChatWire wire )
	{
		var input = RequireList( wire.Messages, MaximumChatMessages, "chat messages" );
		var messages = new List<ChatMessageSnapshot>( input.Count );
		foreach ( var message in input )
		{
			messages.Add( new ChatMessageSnapshot(
				message.MessageId,
				RequireWireString( message.ChannelId, "chat channel" ),
				message.AuthorCharacterId is Guid author ? new CharacterId( author ) : null,
				RequireWireString( message.AuthorName, "chat author" ),
				RequireWireString( message.Text, "chat text" ),
				message.SentAtUtc ) );
		}
		return new ChatSnapshot(
			new ChatDeliveryEpoch( new ConnectionEpoch( wire.Connection ), wire.Character ),
			wire.Revision,
			messages );
	}

	private static ClientStateWire ToWire( ClientStateSnapshot snapshot )
	{
		RequireCount( snapshot.Roster.Rows.Count, MaximumRosterRows, "roster rows" );
		RequireCount( snapshot.SchemaViews.Count, MaximumSchemaViews, "schema views" );
		RequireCount( snapshot.Inventories.Count, MaximumInventories, "inventories" );
		return new ClientStateWire
		{
			Epoch = new ClientStateEpochWire
			{
				Connection = snapshot.Epoch.Connection.Value,
				Character = snapshot.Epoch.Character,
				Revision = snapshot.Epoch.Revision
			},
			Player = ToWire( snapshot.Player ),
			PrivatePlayer = snapshot.PrivatePlayer is null ? null : ToWire( snapshot.PrivatePlayer ),
			Roster = new RosterWire
			{
				Revision = snapshot.Roster.Revision,
				Rows = snapshot.Roster.Rows.Select( row => new RosterRowWire
				{
					ConnectionId = row.ConnectionId.Value,
					CharacterId = row.CharacterId?.Value,
					Fields = CopyMap( row.Fields )
				} ).ToList()
			},
			SchemaViews = snapshot.SchemaViews.Values.Select( ToWire ).ToList(),
			Inventories = snapshot.Inventories.Select( ToWire ).ToList(),
			ActiveAction = snapshot.ActiveAction is null ? null : ToWire( snapshot.ActiveAction )
		};
	}

	private static PlayerWire ToWire( PlayerPublicSnapshot snapshot ) => new()
	{
		ConnectionId = snapshot.ConnectionId.Value,
		PlatformAccountId = snapshot.PlatformAccountId,
		PlatformDisplayName = snapshot.PlatformDisplayName,
		CharacterId = snapshot.CharacterId?.Value,
		CharacterName = snapshot.CharacterName,
		ReplicatedCharacterName = snapshot.ReplicatedCharacterName,
		Description = snapshot.Description,
		Model = snapshot.Model?.Value,
		Faction = snapshot.Faction?.Value,
		Class = snapshot.Class?.Value,
		IsDead = snapshot.IsDead,
		IsWeaponRaised = snapshot.IsWeaponRaised
	};

	private static PrivatePlayerWire ToWire( PlayerPrivateSnapshot snapshot )
	{
		RequireCount( snapshot.Permissions.Count, MaximumMapEntries, "permissions" );
		return new PrivatePlayerWire
		{
			CharacterId = snapshot.CharacterId.Value,
			Balance = snapshot.Balance,
			MainInventoryId = snapshot.MainInventoryId?.Value,
			Values = CopyMap( snapshot.Values ),
			Permissions = snapshot.Permissions.ToList()
		};
	}

	private static SchemaViewWire ToWire( SchemaViewSnapshot snapshot )
	{
		RequireCount( snapshot.Rows.Count, MaximumSchemaRows, "schema rows" );
		return new SchemaViewWire
		{
			PanelId = snapshot.PanelId,
			Revision = snapshot.Revision,
			Fields = CopyMap( snapshot.Fields ),
			Rows = snapshot.Rows.Select( CopyMap ).ToList()
		};
	}

	private static InventoryWire ToWire( InventorySnapshot snapshot )
	{
		RequireCount( snapshot.Items.Count, MaximumInventoryItems, "inventory items" );
		return new InventoryWire
		{
			InventoryId = snapshot.InventoryId.Value,
			Revision = snapshot.Revision,
			Kind = (int)snapshot.Kind,
			Title = snapshot.Title,
			Width = snapshot.Width,
			Height = snapshot.Height,
			Items = snapshot.Items.Select( item =>
			{
				RequireCount( item.Actions.Count, MaximumItemActions, "item actions" );
				return new InventoryItemWire
				{
					ItemId = item.ItemId.Value,
					DefinitionId = item.DefinitionId.Value,
					DisplayName = item.DisplayName,
					Description = item.Description,
					Category = item.Category,
					X = item.X,
					Y = item.Y,
					Width = item.Width,
					Height = item.Height,
					Quantity = item.Quantity,
					Actions = item.Actions.Select( action => new ItemActionWire
					{
						ActionId = action.ActionId.Value,
						Label = action.Label,
						Enabled = action.Enabled,
						DisabledReason = action.DisabledReason,
						Invocation = (int)action.Invocation
					} ).ToList(),
					State = CopyMap( item.State ),
					CanDrop = item.CanDrop,
					DropDisabledReason = item.DropDisabledReason
				};
			} ).ToList()
		};
	}

	private static ActionProgressWire ToWire( ActionProgressSnapshot snapshot ) => new()
	{
		InstanceId = snapshot.InstanceId,
		ActionId = snapshot.ActionId.Value,
		Label = snapshot.Label,
		StartedAtUtc = snapshot.StartedAtUtc,
		DurationTicks = snapshot.Duration.Ticks,
		CanCancel = snapshot.CanCancel
	};

	private static ClientStateSnapshot FromWire( ClientStateWire wire )
	{
		if ( wire.Epoch is null || wire.Player is null || wire.Roster is null )
			throw new JsonException( "Client state is missing required objects." );

		var epoch = new ClientStateEpoch(
			new ConnectionEpoch( wire.Epoch.Connection ),
			wire.Epoch.Character,
			wire.Epoch.Revision );
		var player = FromWire( wire.Player );
		var privatePlayer = wire.PrivatePlayer is null ? null : FromWire( wire.PrivatePlayer );
		var rosterRowsInput = RequireList( wire.Roster.Rows, MaximumRosterRows, "roster rows" );
		var rosterRows = new List<PlayerRosterRowSnapshot>( rosterRowsInput.Count );
		foreach ( var row in rosterRowsInput )
		{
			rosterRows.Add( new PlayerRosterRowSnapshot(
				new ConnectionId( row.ConnectionId ),
				row.CharacterId is Guid characterId ? new CharacterId( characterId ) : null,
				RequireMap( row.Fields, "roster fields" ) ) );
		}

		var viewInput = RequireList( wire.SchemaViews, MaximumSchemaViews, "schema views" );
		var views = new List<SchemaViewSnapshot>( viewInput.Count );
		foreach ( var view in viewInput ) views.Add( FromWire( view ) );

		var inventoryInput = RequireList( wire.Inventories, MaximumInventories, "inventories" );
		var inventories = new List<InventorySnapshot>( inventoryInput.Count );
		foreach ( var inventory in inventoryInput ) inventories.Add( FromWire( inventory ) );

		return new ClientStateSnapshot(
			epoch,
			player,
			privatePlayer,
			new PlayerRosterSnapshot( wire.Roster.Revision, rosterRows ),
			views,
			inventories,
			wire.ActiveAction is null ? null : FromWire( wire.ActiveAction ) );
	}

	private static PlayerPublicSnapshot FromWire( PlayerWire wire ) => new(
		new ConnectionId( wire.ConnectionId ),
		wire.PlatformAccountId,
		RequireWireString( wire.PlatformDisplayName, "platform display name" ),
		wire.CharacterId is Guid characterId ? new CharacterId( characterId ) : null,
		RequireWireString( wire.CharacterName, "character name" ),
		RequireWireString( wire.Description, "player description" ),
		wire.Model is null ? null : new DefinitionId( RequireWireString( wire.Model, "player model" ) ),
		wire.Faction is null ? null : new FactionId( RequireWireString( wire.Faction, "player faction" ) ),
		wire.Class is null ? null : new ClassId( RequireWireString( wire.Class, "player class" ) ),
		wire.IsDead,
		wire.IsWeaponRaised,
		RequireWireString( wire.ReplicatedCharacterName, "replicated character name" ) );

	private static PlayerPrivateSnapshot FromWire( PrivatePlayerWire wire )
	{
		if ( wire.Permissions is null ) throw new JsonException( "permissions are required." );
		if ( wire.Permissions.Count > MaximumMapEntries ) throw new JsonException( "permissions has too many entries." );
		if ( wire.Permissions.Any( permission => permission is null ) ) throw new JsonException( "permissions contains null entries." );
		foreach ( var permission in wire.Permissions ) RequireWireString( permission, "permission" );
		return new PlayerPrivateSnapshot(
			new CharacterId( wire.CharacterId ),
			wire.Balance,
			wire.MainInventoryId is Guid inventoryId ? new InventoryId( inventoryId ) : null,
			RequireMap( wire.Values, "private values" ),
			wire.Permissions );
	}

	private static SchemaViewSnapshot FromWire( SchemaViewWire wire )
	{
		var rowInput = RequireList( wire.Rows, MaximumSchemaRows, "schema rows" );
		var rows = new List<IReadOnlyDictionary<string, SnapshotValue>>( rowInput.Count );
		foreach ( var row in rowInput ) rows.Add( RequireMap( row, "schema row" ) );
		return new SchemaViewSnapshot(
			RequireWireString( wire.PanelId, "panel ID" ),
			wire.Revision,
			RequireMap( wire.Fields, "schema fields" ),
			rows );
	}

	private static InventorySnapshot FromWire( InventoryWire wire )
	{
		if ( !Enum.IsDefined( typeof(InventoryViewKind), wire.Kind ) )
			throw new JsonException( "Unknown inventory view kind." );
		var itemInput = RequireList( wire.Items, MaximumInventoryItems, "inventory items" );
		var items = new List<InventoryItemSnapshot>( itemInput.Count );
		foreach ( var item in itemInput ) items.Add( FromWire( item ) );
		return new InventorySnapshot(
			new InventoryId( wire.InventoryId ),
			wire.Revision,
			(InventoryViewKind)wire.Kind,
			RequireWireString( wire.Title, "inventory title" ),
			wire.Width,
			wire.Height,
			items );
	}

	private static InventoryItemSnapshot FromWire( InventoryItemWire wire )
	{
		var actionInput = RequireList( wire.Actions, MaximumItemActions, "item actions" );
		var actions = new List<ItemActionSnapshot>( actionInput.Count );
		foreach ( var action in actionInput )
		{
			if ( !Enum.IsDefined( typeof(ItemActionInvocationKind), action.Invocation ) )
				throw new JsonException( "Unknown item action invocation kind." );
			actions.Add( new ItemActionSnapshot(
				new ActionId( RequireWireString( action.ActionId, "action ID" ) ),
				RequireWireString( action.Label, "action label" ),
				action.Enabled,
				action.DisabledReason is null ? null : RequireWireString( action.DisabledReason, "disabled reason" ),
				(ItemActionInvocationKind)action.Invocation ) );
		}
		return new InventoryItemSnapshot(
			new ItemId( wire.ItemId ),
			new DefinitionId( RequireWireString( wire.DefinitionId, "item definition" ) ),
			RequireWireString( wire.DisplayName, "item display name" ),
			RequireWireString( wire.Description, "item description" ),
			RequireWireString( wire.Category, "item category" ),
			wire.X,
			wire.Y,
			wire.Width,
			wire.Height,
			wire.Quantity,
			actions,
			RequireMap( wire.State, "item state" ),
			wire.CanDrop,
			wire.DropDisabledReason is null ? null : RequireWireString( wire.DropDisabledReason, "drop disabled reason" ) );
	}

	private static ActionProgressSnapshot FromWire( ActionProgressWire wire ) => new(
		wire.InstanceId,
		new ActionId( RequireWireString( wire.ActionId, "active action ID" ) ),
		RequireWireString( wire.Label, "active action label" ),
		wire.StartedAtUtc,
		TimeSpan.FromTicks( wire.DurationTicks ),
		wire.CanCancel );

	private sealed class SnapshotValueJsonConverter : JsonConverter<SnapshotValue>
	{
		public override SnapshotValue Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
		{
			using var document = JsonDocument.ParseValue( ref reader );
			var root = document.RootElement;
			if ( root.ValueKind != JsonValueKind.Object ) throw new JsonException( "Snapshot value must be an object." );
			JsonElement kind = default;
			JsonElement value = default;
			var hasKind = false;
			var hasValue = false;
			foreach ( var property in root.EnumerateObject() )
			{
				switch ( property.Name )
				{
					case "kind" when !hasKind:
						kind = property.Value;
						hasKind = true;
						break;
					case "value" when !hasValue:
						value = property.Value;
						hasValue = true;
						break;
					default:
						throw new JsonException( "Snapshot value has duplicate or unknown properties." );
				}
			}
			if ( !hasKind || !hasValue || kind.ValueKind != JsonValueKind.String )
				throw new JsonException( "Snapshot value requires kind and value." );

			return kind.GetString() switch
			{
				"string" when value.ValueKind == JsonValueKind.String =>
					SnapshotValue.String( RequireWireString( value.GetString(), "snapshot string" ) ),
				"choice" when value.ValueKind == JsonValueKind.String =>
					SnapshotValue.Choice( RequireWireString( value.GetString(), "snapshot choice" ) ),
				"integer" when value.ValueKind == JsonValueKind.Number && value.TryGetInt64( out var integer ) =>
					SnapshotValue.Integer( integer ),
				"boolean" when value.ValueKind is JsonValueKind.True or JsonValueKind.False =>
					SnapshotValue.Boolean( value.GetBoolean() ),
				_ => throw new JsonException( "Snapshot value kind or scalar type is invalid." )
			};
		}

		public override void Write( Utf8JsonWriter writer, SnapshotValue value, JsonSerializerOptions options )
		{
			writer.WriteStartObject();
			switch ( value.Kind )
			{
				case SnapshotValueKind.String:
					writer.WriteString( "kind", "string" );
					writer.WriteString( "value", RequireWireString( value.StringValue, "snapshot string" ) );
					break;
				case SnapshotValueKind.Choice:
					writer.WriteString( "kind", "choice" );
					writer.WriteString( "value", RequireWireString( value.StringValue, "snapshot choice" ) );
					break;
				case SnapshotValueKind.Integer:
					writer.WriteString( "kind", "integer" );
					writer.WriteNumber( "value", value.IntegerValue );
					break;
				case SnapshotValueKind.Boolean:
					writer.WriteString( "kind", "boolean" );
					writer.WriteBoolean( "value", value.BooleanValue );
					break;
				default:
					throw new JsonException( "Unknown snapshot value kind." );
			}
			writer.WriteEndObject();
		}
	}

	private abstract class WireObject : IJsonOnDeserialized
	{
		[JsonExtensionData]
		public Dictionary<string, JsonElement>? ExtensionData { get; set; }

		void IJsonOnDeserialized.OnDeserialized()
		{
			if ( ExtensionData is { Count: > 0 } )
				throw new JsonException( "Snapshot wire object has unknown properties." );
		}
	}

	private sealed class WireEnvelope<T> : WireObject where T : class
	{
		[JsonRequired] public int Version { get; set; }
		[JsonRequired] public string Kind { get; set; } = null!;
		[JsonRequired] public T Payload { get; set; } = null!;
	}

	private sealed class CharacterListWire : WireObject
	{
		[JsonRequired] public long Revision { get; set; }
		[JsonRequired] public List<CharacterWire> Characters { get; set; } = null!;
	}

	private sealed class CharacterWire : WireObject
	{
		[JsonRequired] public Guid CharacterId { get; set; }
		[JsonRequired] public int Slot { get; set; }
		[JsonRequired] public string Name { get; set; } = null!;
		[JsonRequired] public string Description { get; set; } = null!;
		[JsonRequired] public string Model { get; set; } = null!;
		[JsonRequired] public string Faction { get; set; } = null!;
		[JsonRequired] public string? Class { get; set; }
		[JsonRequired] public DateTimeOffset LastPlayedAt { get; set; }
		[JsonRequired] public bool IsBanned { get; set; }
	}

	private sealed class ChatWire : WireObject
	{
		[JsonRequired] public Guid Connection { get; set; }
		[JsonRequired] public long Character { get; set; }
		[JsonRequired] public long Revision { get; set; }
		[JsonRequired] public List<ChatMessageWire> Messages { get; set; } = null!;
	}

	private sealed class ChatMessageWire : WireObject
	{
		[JsonRequired] public Guid MessageId { get; set; }
		[JsonRequired] public string ChannelId { get; set; } = null!;
		[JsonRequired] public Guid? AuthorCharacterId { get; set; }
		[JsonRequired] public string AuthorName { get; set; } = null!;
		[JsonRequired] public string Text { get; set; } = null!;
		[JsonRequired] public DateTimeOffset SentAtUtc { get; set; }
	}

	private sealed class ClientStateWire : WireObject
	{
		[JsonRequired] public ClientStateEpochWire Epoch { get; set; } = null!;
		[JsonRequired] public PlayerWire Player { get; set; } = null!;
		[JsonRequired] public PrivatePlayerWire? PrivatePlayer { get; set; }
		[JsonRequired] public RosterWire Roster { get; set; } = null!;
		[JsonRequired] public List<SchemaViewWire> SchemaViews { get; set; } = null!;
		[JsonRequired] public List<InventoryWire> Inventories { get; set; } = null!;
		[JsonRequired] public ActionProgressWire? ActiveAction { get; set; }
	}

	private sealed class ClientStateEpochWire : WireObject
	{
		[JsonRequired] public Guid Connection { get; set; }
		[JsonRequired] public long Character { get; set; }
		[JsonRequired] public long Revision { get; set; }
	}

	private sealed class PlayerWire : WireObject
	{
		[JsonRequired] public Guid ConnectionId { get; set; }
		[JsonRequired] public ulong PlatformAccountId { get; set; }
		[JsonRequired] public string PlatformDisplayName { get; set; } = null!;
		[JsonRequired] public Guid? CharacterId { get; set; }
		[JsonRequired] public string CharacterName { get; set; } = null!;
		[JsonRequired] public string ReplicatedCharacterName { get; set; } = null!;
		[JsonRequired] public string Description { get; set; } = null!;
		[JsonRequired] public string? Model { get; set; }
		[JsonRequired] public string? Faction { get; set; }
		[JsonRequired] public string? Class { get; set; }
		[JsonRequired] public bool IsDead { get; set; }
		[JsonRequired] public bool IsWeaponRaised { get; set; }
	}

	private sealed class PrivatePlayerWire : WireObject
	{
		[JsonRequired] public Guid CharacterId { get; set; }
		[JsonRequired] public long Balance { get; set; }
		[JsonRequired] public Guid? MainInventoryId { get; set; }
		[JsonRequired] public Dictionary<string, SnapshotValue> Values { get; set; } = null!;
		[JsonRequired] public List<string> Permissions { get; set; } = null!;
	}

	private sealed class RosterWire : WireObject
	{
		[JsonRequired] public long Revision { get; set; }
		[JsonRequired] public List<RosterRowWire> Rows { get; set; } = null!;
	}

	private sealed class RosterRowWire : WireObject
	{
		[JsonRequired] public Guid ConnectionId { get; set; }
		[JsonRequired] public Guid? CharacterId { get; set; }
		[JsonRequired] public Dictionary<string, SnapshotValue> Fields { get; set; } = null!;
	}

	private sealed class SchemaViewWire : WireObject
	{
		[JsonRequired] public string PanelId { get; set; } = null!;
		[JsonRequired] public long Revision { get; set; }
		[JsonRequired] public Dictionary<string, SnapshotValue> Fields { get; set; } = null!;
		[JsonRequired] public List<Dictionary<string, SnapshotValue>> Rows { get; set; } = null!;
	}

	private sealed class InventoryWire : WireObject
	{
		[JsonRequired] public Guid InventoryId { get; set; }
		[JsonRequired] public long Revision { get; set; }
		[JsonRequired] public int Kind { get; set; }
		[JsonRequired] public string Title { get; set; } = null!;
		[JsonRequired] public int Width { get; set; }
		[JsonRequired] public int Height { get; set; }
		[JsonRequired] public List<InventoryItemWire> Items { get; set; } = null!;
	}

	private sealed class InventoryItemWire : WireObject
	{
		[JsonRequired] public Guid ItemId { get; set; }
		[JsonRequired] public string DefinitionId { get; set; } = null!;
		[JsonRequired] public string DisplayName { get; set; } = null!;
		[JsonRequired] public string Description { get; set; } = null!;
		[JsonRequired] public string Category { get; set; } = null!;
		[JsonRequired] public int X { get; set; }
		[JsonRequired] public int Y { get; set; }
		[JsonRequired] public int Width { get; set; }
		[JsonRequired] public int Height { get; set; }
		[JsonRequired] public long Quantity { get; set; }
		[JsonRequired] public List<ItemActionWire> Actions { get; set; } = null!;
		[JsonRequired] public Dictionary<string, SnapshotValue> State { get; set; } = null!;
		[JsonRequired] public bool CanDrop { get; set; }
		[JsonRequired] public string? DropDisabledReason { get; set; }
	}

	private sealed class ItemActionWire : WireObject
	{
		[JsonRequired] public string ActionId { get; set; } = null!;
		[JsonRequired] public string Label { get; set; } = null!;
		[JsonRequired] public bool Enabled { get; set; }
		[JsonRequired] public string? DisabledReason { get; set; }
		[JsonRequired] public int Invocation { get; set; }
	}

	private sealed class ActionProgressWire : WireObject
	{
		[JsonRequired] public Guid InstanceId { get; set; }
		[JsonRequired] public string ActionId { get; set; } = null!;
		[JsonRequired] public string Label { get; set; } = null!;
		[JsonRequired] public DateTimeOffset StartedAtUtc { get; set; }
		[JsonRequired] public long DurationTicks { get; set; }
		[JsonRequired] public bool CanCancel { get; set; }
	}
}