using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Globalization; namespace GameCult.Networking { /// /// Registers exchange-safe schema metadata and produces discovery responses. /// public sealed class CultNetSchemaRegistry { private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); /// /// Creates a schema registry with optional initial entries. /// public CultNetSchemaRegistry(IEnumerable? entries = null) { if (entries == null) { return; } foreach (var entry in entries) { Register(entry); } } /// /// Adds or replaces one schema registration. /// public CultNetSchemaRegistry Register(CultNetSchemaRegistration entry) { if (entry == null) throw new ArgumentNullException(nameof(entry)); if (string.IsNullOrWhiteSpace(entry.SchemaId)) throw new ArgumentException("SchemaId must be non-empty.", nameof(entry)); if (string.IsNullOrWhiteSpace(entry.Kind)) throw new ArgumentException("Kind must be non-empty.", nameof(entry)); if (entry.WireContracts == null || entry.WireContracts.Length == 0) { throw new ArgumentException("WireContracts must contain at least one entry.", nameof(entry)); } var canonicalSchemaJson = CanonicalizeJson(entry.SchemaJson); var contentHash = ComputeSha256Hex(canonicalSchemaJson); _entries[entry.SchemaId] = new RegisteredSchema(entry, canonicalSchemaJson, contentHash); return this; } /// /// Gets one schema descriptor by schema identifier. /// public CultNetSchemaDescriptor? Get(string schemaId, bool includeSchemaJson = false) { if (schemaId == null) throw new ArgumentNullException(nameof(schemaId)); return _entries.TryGetValue(schemaId, out var entry) ? entry.ToDescriptor(includeSchemaJson) : null; } /// /// Lists schema descriptors, optionally filtered by identifier or kind. /// public CultNetSchemaDescriptor[] List( bool includeSchemaJson = false, IEnumerable? schemaIds = null, IEnumerable? kinds = null) { var requestedSchemaIds = schemaIds != null ? new HashSet(schemaIds, StringComparer.Ordinal) : null; var requestedKinds = kinds != null ? new HashSet(kinds, StringComparer.Ordinal) : null; return _entries.Values .Where(entry => (requestedSchemaIds == null || requestedSchemaIds.Contains(entry.SchemaId)) && (requestedKinds == null || requestedKinds.Contains(entry.Kind))) .Select(entry => entry.ToDescriptor(includeSchemaJson)) .ToArray(); } /// /// Creates a schema catalog response for a request. /// public CultNetSchemaCatalogResponseMessage CreateCatalogResponse( CultNetSchemaCatalogRequestMessage request) { if (request == null) throw new ArgumentNullException(nameof(request)); return new CultNetSchemaCatalogResponseMessage { MessageId = request.MessageId, Schemas = List( includeSchemaJson: request.IncludeSchemaJson, schemaIds: request.SchemaIds, kinds: request.Kinds) }; } /// /// Creates a registry populated from embedded CultNet schema resources. /// public static CultNetSchemaRegistry CreateBuiltIn() { return new CultNetSchemaRegistry(BuiltInSchemaManifest.Select(LoadEmbeddedRegistration)); } private static readonly Lazy BuiltInRegistry = new Lazy(CreateBuiltIn); /// /// Gets the lazily initialized built-in schema registry. /// public static CultNetSchemaRegistry BuiltIn => BuiltInRegistry.Value; private static CultNetSchemaRegistration LoadEmbeddedRegistration(SchemaResourceSpec spec) { var resourceName = $"GameCult.Networking.Contracts.{spec.FileName}"; using var stream = typeof(CultNetSchemaRegistry).Assembly.GetManifestResourceStream(resourceName) ?? throw new InvalidOperationException($"Missing embedded CultNet schema resource \"{resourceName}\"."); using var reader = new StreamReader(stream, Encoding.UTF8); var schemaJson = reader.ReadToEnd(); using var document = JsonDocument.Parse(schemaJson); var root = document.RootElement; var schemaId = root.GetProperty("$id").GetString() ?? throw new InvalidOperationException($"Schema resource \"{resourceName}\" is missing $id."); var title = root.TryGetProperty("title", out var titleElement) ? titleElement.GetString() : null; return new CultNetSchemaRegistration { SchemaId = schemaId, Kind = spec.Kind, SchemaVersion = spec.SchemaVersion, DocumentType = spec.DocumentType, Title = title, WireContracts = spec.WireContracts.ToArray(), SchemaJson = schemaJson }; } private static string CanonicalizeJson(string schemaJson) { using var document = JsonDocument.Parse(schemaJson); using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream)) { WriteCanonicalElement(document.RootElement, writer); } return Encoding.UTF8.GetString(stream.ToArray()); } private static void WriteCanonicalElement(JsonElement element, Utf8JsonWriter writer) { switch (element.ValueKind) { case JsonValueKind.Object: writer.WriteStartObject(); foreach (var property in element.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal)) { writer.WritePropertyName(property.Name); WriteCanonicalElement(property.Value, writer); } writer.WriteEndObject(); break; case JsonValueKind.Array: writer.WriteStartArray(); foreach (var item in element.EnumerateArray()) { WriteCanonicalElement(item, writer); } writer.WriteEndArray(); break; default: element.WriteTo(writer); break; } } private static string ComputeSha256Hex(string canonicalSchemaJson) { using var sha = SHA256.Create(); var bytes = Encoding.UTF8.GetBytes(canonicalSchemaJson); var hash = sha.ComputeHash(bytes); var builder = new StringBuilder(hash.Length * 2); foreach (var value in hash) { builder.Append(value.ToString("x2", CultureInfo.InvariantCulture)); } return builder.ToString(); } private sealed class RegisteredSchema { public RegisteredSchema(CultNetSchemaRegistration registration, string canonicalSchemaJson, string contentHash) { SchemaId = registration.SchemaId; Kind = registration.Kind; SchemaVersion = registration.SchemaVersion; DocumentType = registration.DocumentType; Title = registration.Title; WireContracts = registration.WireContracts.ToArray(); CanonicalSchemaJson = canonicalSchemaJson; ContentHash = contentHash; } public string SchemaId { get; } public string Kind { get; } public string? SchemaVersion { get; } public string? DocumentType { get; } public string? Title { get; } public string[] WireContracts { get; } public string CanonicalSchemaJson { get; } public string ContentHash { get; } public CultNetSchemaDescriptor ToDescriptor(bool includeSchemaJson) { return new CultNetSchemaDescriptor { SchemaId = SchemaId, Kind = Kind, SchemaVersion = SchemaVersion, DocumentType = DocumentType, Title = Title, WireContracts = WireContracts.ToArray(), ContentHash = ContentHash, SchemaJson = includeSchemaJson ? CanonicalSchemaJson : null }; } } private sealed class SchemaResourceSpec { public string FileName { get; set; } = string.Empty; public string Kind { get; set; } = string.Empty; public string[] WireContracts { get; set; } = Array.Empty(); public string? SchemaVersion { get; set; } public string? DocumentType { get; set; } } private static readonly SchemaResourceSpec[] BuiltInSchemaManifest = { new SchemaResourceSpec { FileName = "cultnet.hello.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.Hello, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.document-mutation-contract.schema.json", Kind = "shared_contract", WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.transport-profile.schema.json", Kind = "shared_contract", SchemaVersion = "cultnet.transport_profile.v0", WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.login.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.Login, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.register.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.Register, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.verify.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.Verify, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.login-success.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.LoginSuccess, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.error.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.Error, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.sample-change-name.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.SampleChangeName, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.sample-chat.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.SampleChat, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.operation-request.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.OperationRequest, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.operation-response.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.OperationResponse, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.document-delete.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.DocumentDelete, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.raw-document-record.schema.json", Kind = "shared_contract", WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.document-put-raw.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.DocumentPutRaw, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.snapshot-request.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.SnapshotRequest, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.snapshot-response-raw.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.SnapshotResponseRaw, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.database-subscribe.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.DatabaseSubscribe, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.selection.schema.json", Kind = "shared_contract", WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.snapshot-request.v1.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.SnapshotRequestV1, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.database-subscribe.v1.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.DatabaseSubscribeV1, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.snapshot-response-raw.v1.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.SnapshotResponseRawV1, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.database-unsubscribe.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.DatabaseUnsubscribe, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.database-change-raw.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.DatabaseChangeRaw, WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.schema-descriptor.schema.json", Kind = "shared_contract", WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.schema-catalog-request.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.SchemaCatalogRequest, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "cultnet.schema-catalog-response.schema.json", Kind = "wire_message", SchemaVersion = CultNetSchemaVersions.SchemaCatalogResponse, WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec { FileName = "ghostlight.agent-state.schema.json", Kind = "document_payload", SchemaVersion = "ghostlight.agent_state.v0", DocumentType = "ghostlight.agent-state", WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec { FileName = "cultnet.witness-artifact-bundle.schema.json", Kind = "document_payload", SchemaVersion = CultNetSchemaVersions.WitnessArtifactBundle, DocumentType = "cultnet.witness-artifact-bundle", WireContracts = new[] { CultNetWireContracts.SchemaV0 } } }; } /// /// One exchange-safe schema registration. /// public sealed class CultNetSchemaRegistration { /// /// Gets or sets the schema identifier. /// public string SchemaId { get; set; } = string.Empty; /// /// Gets or sets the schema kind. /// public string Kind { get; set; } = string.Empty; /// /// Gets or sets the wire contracts implemented by the schema. /// public string[] WireContracts { get; set; } = Array.Empty(); /// /// Gets or sets the schema document. /// public string SchemaJson { get; set; } = string.Empty; /// /// Gets or sets the message or document schema version. /// public string? SchemaVersion { get; set; } /// /// Gets or sets the document type associated with the schema. /// public string? DocumentType { get; set; } /// /// Gets or sets the display title. /// public string? Title { get; set; } } }