Add schema discovery messages to CultNet

This commit is contained in:
Meta Vens 2026-05-06 10:50:11 +01:00
commit 0e452b096c
3 changed files with 136 additions and 0 deletions

View file

@ -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;
}
/// <summary>
/// Requests a catalog of schemas that the remote runtime can safely exchange.
/// </summary>
[MessagePackObject]
public class SchemaCatalogRequestMessage : Message
{
/// <summary>
/// Correlation identifier for the discovery request.
/// </summary>
[Key(0)] public string MessageId = string.Empty;
/// <summary>
/// Whether the remote runtime should include inline JSON schema bodies.
/// </summary>
[Key(1)] public bool IncludeSchemaJson;
/// <summary>
/// Optional schema-id filter.
/// </summary>
[Key(2)] public string[]? SchemaIds;
/// <summary>
/// Optional schema-kind filter.
/// </summary>
[Key(3)] public string[]? Kinds;
}
/// <summary>
/// Describes one schema that the runtime knows how to exchange safely.
/// </summary>
[MessagePackObject]
public class SchemaDescriptorMessage
{
/// <summary>
/// Stable canonical schema identifier.
/// </summary>
[Key(0)] public string SchemaId = string.Empty;
/// <summary>
/// Discovery kind such as wire_message, document_payload, or shared_contract.
/// </summary>
[Key(1)] public string Kind = string.Empty;
/// <summary>
/// Optional runtime message schema version.
/// </summary>
[Key(2)] public string? SchemaVersion;
/// <summary>
/// Optional CultCache/CultNet document type name.
/// </summary>
[Key(3)] public string? DocumentType;
/// <summary>
/// Human-facing schema title.
/// </summary>
[Key(4)] public string? Title;
/// <summary>
/// Wire contracts that can transport this schema safely.
/// </summary>
[Key(5)] public string[] WireContracts = Array.Empty<string>();
/// <summary>
/// SHA-256 hash of the canonical schema JSON.
/// </summary>
[Key(6)] public string ContentHash = string.Empty;
/// <summary>
/// Optional canonical JSON Schema body as a string.
/// </summary>
[Key(7)] public string? SchemaJson;
}
/// <summary>
/// Returns the remote runtime's schema catalog for safe exchange planning.
/// </summary>
[MessagePackObject]
public class SchemaCatalogResponseMessage : Message
{
/// <summary>
/// Correlation identifier matching the request.
/// </summary>
[Key(0)] public string MessageId = string.Empty;
/// <summary>
/// Discovered schemas available for safe exchange.
/// </summary>
[Key(1)] public SchemaDescriptorMessage[] Schemas = Array.Empty<SchemaDescriptorMessage>();
}
}

View file

@ -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.

View file

@ -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()
{