diff --git a/src/GameCult.Networking/Messages.cs b/src/GameCult.Networking/Messages.cs index 331dfa43..aa14a732 100644 --- a/src/GameCult.Networking/Messages.cs +++ b/src/GameCult.Networking/Messages.cs @@ -15,6 +15,8 @@ namespace GameCult.Networking // Sample application payload tags preserved for cross-runtime compatibility. Union(5, typeof(ChangeNameMessage)), Union(6, typeof(ChatMessage)), + Union(7, typeof(SchemaCatalogRequestMessage)), + Union(8, typeof(SchemaCatalogResponseMessage)), MessagePackObject] public abstract class Message { @@ -110,4 +112,95 @@ namespace GameCult.Networking [Key(0)] public string Error = string.Empty; } + /// + /// Requests a catalog of schemas that the remote runtime can safely exchange. + /// + [MessagePackObject] + public class SchemaCatalogRequestMessage : Message + { + /// + /// Correlation identifier for the discovery request. + /// + [Key(0)] public string MessageId = string.Empty; + + /// + /// Whether the remote runtime should include inline JSON schema bodies. + /// + [Key(1)] public bool IncludeSchemaJson; + + /// + /// Optional schema-id filter. + /// + [Key(2)] public string[]? SchemaIds; + + /// + /// Optional schema-kind filter. + /// + [Key(3)] public string[]? Kinds; + } + + /// + /// Describes one schema that the runtime knows how to exchange safely. + /// + [MessagePackObject] + public class SchemaDescriptorMessage + { + /// + /// Stable canonical schema identifier. + /// + [Key(0)] public string SchemaId = string.Empty; + + /// + /// Discovery kind such as wire_message, document_payload, or shared_contract. + /// + [Key(1)] public string Kind = string.Empty; + + /// + /// Optional runtime message schema version. + /// + [Key(2)] public string? SchemaVersion; + + /// + /// Optional CultCache/CultNet document type name. + /// + [Key(3)] public string? DocumentType; + + /// + /// Human-facing schema title. + /// + [Key(4)] public string? Title; + + /// + /// Wire contracts that can transport this schema safely. + /// + [Key(5)] public string[] WireContracts = Array.Empty(); + + /// + /// SHA-256 hash of the canonical schema JSON. + /// + [Key(6)] public string ContentHash = string.Empty; + + /// + /// Optional canonical JSON Schema body as a string. + /// + [Key(7)] public string? SchemaJson; + } + + /// + /// Returns the remote runtime's schema catalog for safe exchange planning. + /// + [MessagePackObject] + public class SchemaCatalogResponseMessage : Message + { + /// + /// Correlation identifier matching the request. + /// + [Key(0)] public string MessageId = string.Empty; + + /// + /// Discovered schemas available for safe exchange. + /// + [Key(1)] public SchemaDescriptorMessage[] Schemas = Array.Empty(); + } + } diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index 3aa7ad3b..30c3a31d 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -26,6 +26,8 @@ Keep the distinction clean: - auth/session semantics belong to the core library - application payload contracts should be explicit, versioned, and kept in sync across runtimes +- runtimes should be able to ask each other which schemas they speak before + pretending a shared pipe implies shared understanding - if multiple apps share a message contract, they should be able to talk directly without bespoke translation sludge @@ -41,6 +43,8 @@ Sample payloads: - `ChangeNameMessage` - `ChatMessage` +- `SchemaCatalogRequestMessage` +- `SchemaCatalogResponseMessage` Those now live under `Samples/` to make it obvious they are example application messages, not the entire meaning of the library. diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 06a17280..887052dc 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -107,6 +107,45 @@ namespace GameCult.Networking.Tests Assert.That(roundTrip.Nonce, Is.EqualTo(message.Nonce)); } + [Test] + public void MessageSerialization_RoundTrips_SchemaCatalogMessages() + { + var client = new Client(DevelopmentClientSecurity); + var message = new SchemaCatalogResponseMessage + { + MessageId = "catalog-1", + Schemas = + [ + new SchemaDescriptorMessage + { + SchemaId = "https://example.test/contracts/example.schema.json", + Kind = "shared_contract", + SchemaVersion = "example.contract.v0", + Title = "Example Contract", + WireContracts = ["cultnet.schema.v0", "gamecult.networking.v0"], + ContentHash = "deadbeef", + SchemaJson = "{\"type\":\"object\"}" + } + ] + }; + + var serializationType = client.GetType().Assembly + .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!; + var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)! + .MakeGenericMethod(typeof(Message)); + var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! + .MakeGenericMethod(typeof(Message)); + + var payload = (byte[])serialize.Invoke(null, [message])!; + var roundTrip = (SchemaCatalogResponseMessage)deserialize.Invoke(null, [payload])!; + + Assert.That(roundTrip.MessageId, Is.EqualTo("catalog-1")); + Assert.That(roundTrip.Schemas, Has.Length.EqualTo(1)); + Assert.That(roundTrip.Schemas[0].SchemaId, Is.EqualTo("https://example.test/contracts/example.schema.json")); + Assert.That(roundTrip.Schemas[0].WireContracts, Is.EqualTo(["cultnet.schema.v0", "gamecult.networking.v0"])); + Assert.That(roundTrip.Schemas[0].SchemaJson, Is.EqualTo("{\"type\":\"object\"}")); + } + [Test] public void MessageSerialization_Rejects_InvalidPayload() {