From 0277ca79d99622353770d22bd9b49b16b39e8caf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 6 May 2026 11:50:10 +0100 Subject: [PATCH] Bring legacy CultNet into the schema-v0 fold --- .../DatabaseEntryMessagePackGenerator.cs | 11 +- .../CultCacheEnvelope.cs | 95 ++ src/GameCult.Caching.MessagePack/README.md | 26 + .../cultnet.document-delete.schema.json | 19 + .../cultnet.document-put-raw.schema.json | 19 + .../cultnet.document-put.schema.json | 19 + .../cultnet.document-record.schema.json | 27 + .../Contracts/cultnet.error.schema.json | 15 + .../Contracts/cultnet.hello.schema.json | 29 + .../cultnet.login-success.schema.json | 17 + .../Contracts/cultnet.login.schema.json | 19 + .../cultnet.raw-document-record.schema.json | 33 + .../Contracts/cultnet.register.schema.json | 21 + .../cultnet.sample-change-name.schema.json | 15 + .../Contracts/cultnet.sample-chat.schema.json | 15 + ...cultnet.schema-catalog-request.schema.json | 31 + ...ultnet.schema-catalog-response.schema.json | 22 + .../cultnet.schema-descriptor.schema.json | 39 + .../cultnet.snapshot-request.schema.json | 23 + .../cultnet.snapshot-response-raw.schema.json | 22 + .../cultnet.snapshot-response.schema.json | 22 + .../Contracts/cultnet.verify.schema.json | 17 + .../ghostlight.agent-state.schema.json | 845 ++++++++++++++++++ .../CultNetDocumentRegistry.cs | 409 +++++++++ .../CultNetSchemaMessageSerialization.cs | 63 ++ .../CultNetSchemaMessages.cs | 193 ++++ .../CultNetSchemaRegistry.cs | 368 ++++++++ .../GameCult.Networking.csproj | 11 +- src/GameCult.Networking/NetPeerExtensions.cs | 11 + src/GameCult.Networking/README.md | 44 + .../NetworkingTests.cs | 141 +++ 31 files changed, 2635 insertions(+), 6 deletions(-) create mode 100644 src/GameCult.Caching.MessagePack/CultCacheEnvelope.cs create mode 100644 src/GameCult.Networking/Contracts/cultnet.document-delete.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.document-put-raw.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.document-put.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.document-record.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.error.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.hello.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.login-success.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.login.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.raw-document-record.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.register.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.sample-change-name.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.sample-chat.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.schema-catalog-request.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.schema-catalog-response.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.schema-descriptor.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.snapshot-request.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.snapshot-response-raw.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.snapshot-response.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.verify.schema.json create mode 100644 src/GameCult.Networking/Contracts/ghostlight.agent-state.schema.json create mode 100644 src/GameCult.Networking/CultNetDocumentRegistry.cs create mode 100644 src/GameCult.Networking/CultNetSchemaMessageSerialization.cs create mode 100644 src/GameCult.Networking/CultNetSchemaMessages.cs create mode 100644 src/GameCult.Networking/CultNetSchemaRegistry.cs diff --git a/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs index 779d605e..5cae7194 100644 --- a/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs +++ b/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs @@ -52,6 +52,7 @@ namespace GameCult.Caching.MessagePack.Generator var sbFormatters = new StringBuilder(); sbResolvers.AppendLine("// "); + sbResolvers.AppendLine("#nullable enable"); sbResolvers.AppendLine("using System;"); sbResolvers.AppendLine("using MessagePack.Formatters;"); sbResolvers.AppendLine("namespace GameCult.Caching.MessagePack"); @@ -86,8 +87,9 @@ namespace GameCult.Caching.MessagePack.Generator private static string Sanitize(ISymbol symbol) { - var s = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + var s = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); return s.Replace(".", "_") + .Replace("global::", string.Empty) .Replace("<", "_") .Replace(">", "_") .Replace(",", "_") @@ -99,14 +101,13 @@ namespace GameCult.Caching.MessagePack.Generator { var sb = new StringBuilder(); sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); sb.AppendLine("using System;"); sb.AppendLine("using MessagePack;"); sb.AppendLine("using MessagePack.Formatters;"); sb.AppendLine(); - var ns = type.ContainingNamespace.IsGlobalNamespace - ? "GameCult.Caching.Generated" - : type.ContainingNamespace.ToDisplayString(); + var ns = "GameCult.Caching.MessagePack"; sb.AppendLine($"namespace {ns}"); sb.AppendLine("{"); @@ -115,7 +116,7 @@ namespace GameCult.Caching.MessagePack.Generator sb.AppendLine($" internal sealed class {formatterName} : IMessagePackFormatter<{typeFull}>"); sb.AppendLine(" {"); sb.AppendLine($" public static readonly {formatterName} Instance = new {formatterName}();"); - sb.AppendLine(" private {formatterName}() { }"); + sb.AppendLine($" private {formatterName}() {{ }}"); sb.AppendLine(); var members = CollectMembers(type); diff --git a/src/GameCult.Caching.MessagePack/CultCacheEnvelope.cs b/src/GameCult.Caching.MessagePack/CultCacheEnvelope.cs new file mode 100644 index 00000000..838fa32d --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultCacheEnvelope.cs @@ -0,0 +1,95 @@ +using System; +using System.Reflection; + +namespace GameCult.Caching.MessagePack; + +/// +/// Represents a canonical CultCache payload envelope for cross-runtime exchange. +/// +public sealed class CultCacheEnvelope +{ + /// + /// Gets or sets the logical document key for the serialized entry. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Gets or sets the shared document type identifier for the serialized entry. + /// + public string Type { get; set; } = string.Empty; + + /// + /// Gets or sets the raw MessagePack payload bytes for the entry. + /// + public byte[] Payload { get; set; } = Array.Empty(); + + /// + /// Gets or sets the RFC3339 timestamp associated with the serialized payload. + /// + public string StoredAt { get; set; } = string.Empty; +} + +/// +/// Exposes canonical MessagePack payload helpers for values. +/// +public static class CultCacheEnvelopeSerialization +{ + /// + /// Serializes a concrete entry to the canonical MessagePack payload bytes used by CultCache. + /// + public static byte[] SerializePayload(DatabaseEntry entry) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + var method = typeof(CultCacheEnvelopeSerialization) + .GetMethod(nameof(SerializeGeneric), BindingFlags.NonPublic | BindingFlags.Static) + ?.MakeGenericMethod(entry.GetType()) + ?? throw new InvalidOperationException("Failed to locate typed CultCache envelope serializer."); + return (byte[])method.Invoke(null, [entry])!; + } + + /// + /// Deserializes canonical MessagePack payload bytes into a concrete entry type. + /// + public static T DeserializePayload(byte[] payload) where T : DatabaseEntry + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + return DatabaseEntrySerialization.Deserialize(payload); + } + + /// + /// Deserializes canonical MessagePack payload bytes into an abstract . + /// + public static DatabaseEntry DeserializePayload(byte[] payload) + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + return DatabaseEntrySerialization.Deserialize(payload); + } + + /// + /// Creates a full exchange envelope for a concrete entry using the supplied metadata. + /// + public static CultCacheEnvelope CreateEnvelope( + DatabaseEntry entry, + string key, + string type, + string storedAt) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentException("Envelope key must be non-empty.", nameof(key)); + if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Envelope type must be non-empty.", nameof(type)); + if (string.IsNullOrWhiteSpace(storedAt)) throw new ArgumentException("Envelope storedAt must be non-empty.", nameof(storedAt)); + + return new CultCacheEnvelope + { + Key = key, + Type = type, + Payload = SerializePayload(entry), + StoredAt = storedAt + }; + } + + private static byte[] SerializeGeneric(T entry) where T : DatabaseEntry + { + return DatabaseEntrySerialization.Serialize(entry); + } +} diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index d53c1d14..c7837019 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -10,6 +10,7 @@ The package focuses on: - single-file and multi-file store implementations - support for generated formatters for concrete cache-entry types - formatter support for `DatabaseLink` +- raw persisted-envelope helpers for exact payload relay ## Included Types @@ -17,6 +18,8 @@ The package focuses on: - `MultiFileMessagePackBackingStore` - `DatabaseEntryResolver` - `DatabaseLinkFormatter` +- `CultCacheEnvelope` +- `CultCacheEnvelopeSerialization` ## Why The Generator Matters @@ -113,3 +116,26 @@ public class ItemData : DatabaseEntry, INamedEntry - For polymorphic `DatabaseEntry` serialization, use this package with the generator/analyzer package. - The backing stores build on the semantics of `CultCache`, including primary-store routing and type-specific store routing. - `MultiFileMessagePackBackingStore` inherits the rename-safe and atomic-write behavior of the hardened `MultiFileBackingStore` base class. + +## Raw Envelope Relay + +If a neighboring process already shares the same payload contract and just +needs the exact persisted MessagePack bytes, use `CultCacheEnvelope` and +`CultCacheEnvelopeSerialization`. + +That gives you: + +- the stable cache key +- the explicit entry/document type +- the exact payload bytes +- the stored-at timestamp + +This is the seam the newer CultNet raw document/snapshot lane uses for +bit-compatible neighbors. It keeps the transport from decoding bytes into a +generic sludge object only to re-encode the same thing again five seconds +later. + +One important constraint: concrete `DatabaseEntry` subclasses still need real +MessagePack formatters. If a consumer assembly has not generated or registered +formatters for a subtype, supply an explicit payload codec at the integration +boundary instead of assuming the abstract resolver will become psychic. diff --git a/src/GameCult.Networking/Contracts/cultnet.document-delete.schema.json b/src/GameCult.Networking/Contracts/cultnet.document-delete.schema.json new file mode 100644 index 00000000..146fd6d3 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.document-delete.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-delete.schema.json", + "title": "CultNet Document Delete Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "documentType", + "documentKey" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.document_delete.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "documentType": { "type": "string", "minLength": 1 }, + "documentKey": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.document-put-raw.schema.json b/src/GameCult.Networking/Contracts/cultnet.document-put-raw.schema.json new file mode 100644 index 00000000..3c178d03 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.document-put-raw.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json", + "title": "CultNet Raw Document Put Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "document" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.document_put_raw.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "document": { + "$ref": "./cultnet.raw-document-record.schema.json" + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.document-put.schema.json b/src/GameCult.Networking/Contracts/cultnet.document-put.schema.json new file mode 100644 index 00000000..a04f5fca --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.document-put.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put.schema.json", + "title": "CultNet Document Put Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "document" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.document_put.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "document": { + "$ref": "./cultnet.document-record.schema.json" + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.document-record.schema.json b/src/GameCult.Networking/Contracts/cultnet.document-record.schema.json new file mode 100644 index 00000000..139d1fd5 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.document-record.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-record.schema.json", + "title": "CultNet Document Record", + "type": "object", + "required": [ + "documentType", + "documentKey", + "storedAt", + "payload" + ], + "additionalProperties": false, + "properties": { + "documentType": { "type": "string", "minLength": 1 }, + "documentKey": { "type": "string", "minLength": 1 }, + "storedAt": { "type": "string", "minLength": 1 }, + "payloadSchemaVersion": { "type": "string", "minLength": 1 }, + "payload": {}, + "sourceRuntimeId": { "type": "string", "minLength": 1 }, + "sourceAgentId": { "type": "string", "minLength": 1 }, + "sourceRole": { "type": "string", "minLength": 1 }, + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.error.schema.json b/src/GameCult.Networking/Contracts/cultnet.error.schema.json new file mode 100644 index 00000000..af6da9cf --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.error.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.error.schema.json", + "title": "CultNet Error Message", + "type": "object", + "required": [ + "schemaVersion", + "error" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.error.v0" }, + "error": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.hello.schema.json b/src/GameCult.Networking/Contracts/cultnet.hello.schema.json new file mode 100644 index 00000000..f15f7452 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.hello.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.hello.schema.json", + "title": "CultNet Hello Message", + "type": "object", + "required": [ + "schemaVersion", + "runtimeId", + "runtimeKind" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.hello.v0" }, + "runtimeId": { "type": "string", "minLength": 1 }, + "runtimeKind": { "type": "string", "minLength": 1 }, + "agentId": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string", "minLength": 1 }, + "supportedDocumentTypes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "supportedMessageVersions": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "supportsSchemaCatalog": { "type": "boolean" } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.login-success.schema.json b/src/GameCult.Networking/Contracts/cultnet.login-success.schema.json new file mode 100644 index 00000000..3d8a4591 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.login-success.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login-success.schema.json", + "title": "CultNet Login Success Message", + "type": "object", + "required": [ + "schemaVersion", + "nonce", + "session" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.login_success.v0" }, + "nonce": { "type": "string", "minLength": 1 }, + "session": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.login.schema.json b/src/GameCult.Networking/Contracts/cultnet.login.schema.json new file mode 100644 index 00000000..62f49599 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.login.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login.schema.json", + "title": "CultNet Login Message", + "type": "object", + "required": [ + "schemaVersion", + "nonce", + "auth", + "password" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.login.v0" }, + "nonce": { "type": "string", "minLength": 1 }, + "auth": { "type": "string", "minLength": 1 }, + "password": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.raw-document-record.schema.json b/src/GameCult.Networking/Contracts/cultnet.raw-document-record.schema.json new file mode 100644 index 00000000..93e3108d --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.raw-document-record.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.raw-document-record.schema.json", + "title": "CultNet Raw Document Record", + "type": "object", + "required": [ + "documentType", + "documentKey", + "storedAt", + "payloadEncoding", + "payload" + ], + "additionalProperties": false, + "properties": { + "documentType": { "type": "string", "minLength": 1 }, + "documentKey": { "type": "string", "minLength": 1 }, + "storedAt": { "type": "string", "minLength": 1 }, + "payloadSchemaVersion": { "type": "string", "minLength": 1 }, + "payloadEncoding": { "const": "messagepack" }, + "payload": { + "type": "string", + "contentEncoding": "base64", + "x-cult-runtime-type": "bytes" + }, + "sourceRuntimeId": { "type": "string", "minLength": 1 }, + "sourceAgentId": { "type": "string", "minLength": 1 }, + "sourceRole": { "type": "string", "minLength": 1 }, + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.register.schema.json b/src/GameCult.Networking/Contracts/cultnet.register.schema.json new file mode 100644 index 00000000..77286f3e --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.register.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.register.schema.json", + "title": "CultNet Register Message", + "type": "object", + "required": [ + "schemaVersion", + "nonce", + "email", + "password", + "name" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.register.v0" }, + "nonce": { "type": "string", "minLength": 1 }, + "email": { "type": "string", "minLength": 1 }, + "password": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.sample-change-name.schema.json b/src/GameCult.Networking/Contracts/cultnet.sample-change-name.schema.json new file mode 100644 index 00000000..fc3fe057 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.sample-change-name.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-change-name.schema.json", + "title": "CultNet Sample Change Name Message", + "type": "object", + "required": [ + "schemaVersion", + "name" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.sample.change_name.v0" }, + "name": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.sample-chat.schema.json b/src/GameCult.Networking/Contracts/cultnet.sample-chat.schema.json new file mode 100644 index 00000000..8426fb57 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.sample-chat.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-chat.schema.json", + "title": "CultNet Sample Chat Message", + "type": "object", + "required": [ + "schemaVersion", + "text" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.sample.chat.v0" }, + "text": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.schema-catalog-request.schema.json b/src/GameCult.Networking/Contracts/cultnet.schema-catalog-request.schema.json new file mode 100644 index 00000000..611733d2 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.schema-catalog-request.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-request.schema.json", + "title": "CultNet Schema Catalog Request Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.schema_catalog_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "includeSchemaJson": { "type": "boolean" }, + "schemaIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "wire_message", + "document_payload", + "shared_contract" + ] + } + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.schema-catalog-response.schema.json b/src/GameCult.Networking/Contracts/cultnet.schema-catalog-response.schema.json new file mode 100644 index 00000000..fa371e48 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.schema-catalog-response.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-response.schema.json", + "title": "CultNet Schema Catalog Response Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "schemas" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.schema_catalog_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "schemas": { + "type": "array", + "items": { + "$ref": "./cultnet.schema-descriptor.schema.json" + } + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.schema-descriptor.schema.json b/src/GameCult.Networking/Contracts/cultnet.schema-descriptor.schema.json new file mode 100644 index 00000000..23796857 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.schema-descriptor.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-descriptor.schema.json", + "title": "CultNet Schema Descriptor", + "type": "object", + "required": [ + "schemaId", + "kind", + "wireContracts", + "contentHash" + ], + "additionalProperties": false, + "properties": { + "schemaId": { "type": "string", "minLength": 1 }, + "kind": { + "type": "string", + "enum": [ + "wire_message", + "document_payload", + "shared_contract" + ] + }, + "schemaVersion": { "type": "string", "minLength": 1 }, + "documentType": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "wireContracts": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "cultnet.schema.v0", + "gamecult.networking.v0" + ] + } + }, + "contentHash": { "type": "string", "minLength": 1 }, + "schemaJson": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.snapshot-request.schema.json b/src/GameCult.Networking/Contracts/cultnet.snapshot-request.schema.json new file mode 100644 index 00000000..47f0c146 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.snapshot-request.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-request.schema.json", + "title": "CultNet Snapshot Request Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.snapshot_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "documentTypes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "documentKeys": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.snapshot-response-raw.schema.json b/src/GameCult.Networking/Contracts/cultnet.snapshot-response-raw.schema.json new file mode 100644 index 00000000..a84bc50d --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.snapshot-response-raw.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response-raw.schema.json", + "title": "CultNet Raw Snapshot Response Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "documents" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.snapshot_response_raw.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "documents": { + "type": "array", + "items": { + "$ref": "./cultnet.raw-document-record.schema.json" + } + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.snapshot-response.schema.json b/src/GameCult.Networking/Contracts/cultnet.snapshot-response.schema.json new file mode 100644 index 00000000..940822ce --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.snapshot-response.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response.schema.json", + "title": "CultNet Snapshot Response Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "documents" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.snapshot_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "documents": { + "type": "array", + "items": { + "$ref": "./cultnet.document-record.schema.json" + } + } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.verify.schema.json b/src/GameCult.Networking/Contracts/cultnet.verify.schema.json new file mode 100644 index 00000000..1f992aa8 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.verify.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.verify.schema.json", + "title": "CultNet Verify Message", + "type": "object", + "required": [ + "schemaVersion", + "nonce", + "session" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.verify.v0" }, + "nonce": { "type": "string", "minLength": 1 }, + "session": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/ghostlight.agent-state.schema.json b/src/GameCult.Networking/Contracts/ghostlight.agent-state.schema.json new file mode 100644 index 00000000..6437cd01 --- /dev/null +++ b/src/GameCult.Networking/Contracts/ghostlight.agent-state.schema.json @@ -0,0 +1,845 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/Ghostlight/schemas/agent-state.schema.json", + "title": "Ghostlight Agent State Schema", + "description": "First canonical contract for Ghostlight agent, perception, relationship, event, scene, and dialogue-context state.", + "type": "object", + "required": [ + "schema_version", + "world", + "agents", + "relationships", + "events", + "scenes" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "ghostlight.agent_state.v0" + }, + "world": { + "$ref": "#/$defs/world_state" + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/$defs/agent" + } + }, + "relationships": { + "type": "array", + "items": { + "$ref": "#/$defs/relationship" + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/$defs/event" + } + }, + "scenes": { + "type": "array", + "items": { + "$ref": "#/$defs/scene" + } + } + }, + "$defs": { + "world_state": { + "type": "object", + "required": [ + "world_id", + "setting", + "time", + "canon_context" + ], + "additionalProperties": false, + "properties": { + "world_id": { + "type": "string" + }, + "setting": { + "type": "string" + }, + "time": { + "type": "object", + "required": [ + "label" + ], + "additionalProperties": true, + "properties": { + "label": { + "type": "string" + } + } + }, + "canon_context": { + "type": "array", + "items": { + "type": "string" + } + }, + "ambient_pressures": { + "type": "array", + "items": { + "$ref": "#/$defs/pressure" + } + } + } + }, + "agent": { + "type": "object", + "required": [ + "agent_id", + "identity", + "canonical_state", + "goals", + "memories", + "perceived_state_overlays" + ], + "additionalProperties": false, + "properties": { + "agent_id": { + "type": "string" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "canonical_state": { + "$ref": "#/$defs/canonical_state" + }, + "goals": { + "type": "array", + "items": { + "$ref": "#/$defs/goal" + } + }, + "memories": { + "$ref": "#/$defs/memory_bundle" + }, + "perceived_state_overlays": { + "type": "array", + "items": { + "$ref": "#/$defs/perceived_state_overlay" + } + } + } + }, + "identity": { + "type": "object", + "required": [ + "name", + "roles", + "origin", + "public_description" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin": { + "type": "string" + }, + "public_description": { + "type": "string" + }, + "private_notes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "canonical_state": { + "type": "object", + "required": [ + "underlying_organization", + "stable_dispositions", + "behavioral_dimensions", + "presentation_strategy", + "voice_style", + "situational_state", + "values" + ], + "additionalProperties": false, + "properties": { + "underlying_organization": { + "$ref": "#/$defs/variable_map" + }, + "stable_dispositions": { + "$ref": "#/$defs/variable_map" + }, + "behavioral_dimensions": { + "$ref": "#/$defs/variable_map" + }, + "presentation_strategy": { + "$ref": "#/$defs/variable_map" + }, + "situational_state": { + "$ref": "#/$defs/variable_map" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/$defs/value_commitment" + } + }, + "voice_style": { + "$ref": "#/$defs/variable_map" + } + } + }, + "variable_map": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/state_variable" + } + }, + "state_variable": { + "type": "object", + "required": [ + "mean", + "plasticity", + "current_activation" + ], + "additionalProperties": false, + "properties": { + "mean": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "plasticity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "current_activation": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "value_commitment": { + "type": "object", + "required": [ + "value_id", + "label", + "priority", + "unforgivable_if_betrayed" + ], + "additionalProperties": false, + "properties": { + "value_id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "priority": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "unforgivable_if_betrayed": { + "type": "boolean" + } + } + }, + "goal": { + "type": "object", + "required": [ + "goal_id", + "description", + "scope", + "priority", + "emotional_stake", + "status" + ], + "additionalProperties": false, + "properties": { + "goal_id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "scope": { + "enum": [ + "immediate", + "scene", + "case", + "arc", + "life" + ] + }, + "priority": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "emotional_stake": { + "type": "string" + }, + "blockers": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "enum": [ + "active", + "blocked", + "dormant", + "resolved", + "abandoned" + ] + } + } + }, + "memory_bundle": { + "type": "object", + "required": [ + "episodic", + "semantic", + "relationship_summaries" + ], + "additionalProperties": false, + "properties": { + "episodic": { + "type": "array", + "items": { + "$ref": "#/$defs/memory" + } + }, + "semantic": { + "type": "array", + "items": { + "$ref": "#/$defs/memory" + } + }, + "relationship_summaries": { + "type": "array", + "items": { + "$ref": "#/$defs/memory" + } + } + } + }, + "memory": { + "type": "object", + "required": [ + "memory_id", + "summary", + "salience", + "confidence" + ], + "additionalProperties": false, + "properties": { + "memory_id": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "salience": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "linked_event_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "linked_relationship_id": { + "type": "string" + } + } + }, + "perceived_state_overlay": { + "type": "object", + "required": [ + "observer_agent_id", + "target_agent_id", + "perceived_dimensions", + "beliefs", + "distortions" + ], + "additionalProperties": false, + "properties": { + "observer_agent_id": { + "type": "string" + }, + "target_agent_id": { + "type": "string" + }, + "perceived_dimensions": { + "$ref": "#/$defs/inferred_variable_map" + }, + "beliefs": { + "type": "array", + "items": { + "$ref": "#/$defs/belief" + } + }, + "distortions": { + "type": "array", + "items": { + "enum": [ + "hostile_attribution", + "false_reassurance", + "overread_warmth", + "underread_threat", + "false_mask_detection", + "missed_mask_detection", + "status_injury", + "threat_amplification", + "institutional_suspicion_transfer", + "care_underread", + "mask_overfocus" + ] + } + } + } + }, + "belief": { + "type": "object", + "required": [ + "belief_id", + "claim", + "confidence" + ], + "additionalProperties": false, + "properties": { + "belief_id": { + "type": "string" + }, + "claim": { + "type": "string" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "subject_id": { + "type": "string" + }, + "claim_type": { + "enum": [ + "fact", + "identity_inference", + "motive_inference", + "prediction", + "norm", + "value_read", + "relationship_read" + ] + }, + "evidence_event_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "enum": [ + "private", + "shared", + "public", + "rumor" + ] + }, + "emotional_charge": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "relationship": { + "type": "object", + "required": [ + "relationship_id", + "source_id", + "target_id", + "stance", + "summary" + ], + "additionalProperties": false, + "properties": { + "relationship_id": { + "type": "string" + }, + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "stance": { + "$ref": "#/$defs/variable_map" + }, + "summary": { + "type": "string" + }, + "unresolved_incident_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "event": { + "type": "object", + "required": [ + "event_id", + "kind", + "summary", + "participants", + "pressure_tags" + ], + "additionalProperties": false, + "properties": { + "event_id": { + "type": "string" + }, + "kind": { + "enum": [ + "dialogue", + "action", + "memory", + "relationship_shift", + "scene_outcome", + "author_note" + ] + }, + "summary": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "type": "string" + } + }, + "pressure_tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "observed_exchange": { + "type": "array", + "items": { + "$ref": "#/$defs/event_exchange_turn" + } + }, + "private_interpretations": { + "type": "array", + "items": { + "$ref": "#/$defs/event_private_interpretation" + } + }, + "event_effects": { + "type": "array", + "items": { + "$ref": "#/$defs/event_effect" + } + } + } + }, + "scene": { + "type": "object", + "required": [ + "scene_id", + "location", + "participants", + "public_stakes", + "hidden_stakes", + "dialogue_context_packs" + ], + "additionalProperties": false, + "properties": { + "scene_id": { + "type": "string" + }, + "location": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "type": "string" + } + }, + "public_stakes": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden_stakes": { + "type": "array", + "items": { + "type": "string" + } + }, + "dialogue_context_packs": { + "type": "array", + "items": { + "$ref": "#/$defs/dialogue_context_pack" + } + } + } + }, + "dialogue_context_pack": { + "type": "object", + "required": [ + "speaker_agent_id", + "listener_ids", + "speaker_local_truth", + "speaker_beliefs", + "active_memories", + "active_goals", + "presentation_constraints" + ], + "additionalProperties": false, + "properties": { + "speaker_agent_id": { + "type": "string" + }, + "listener_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "speaker_local_truth": { + "type": "array", + "items": { + "type": "string" + } + }, + "speaker_beliefs": { + "type": "array", + "items": { + "type": "string" + } + }, + "active_memories": { + "type": "array", + "items": { + "type": "string" + } + }, + "active_goals": { + "type": "array", + "items": { + "type": "string" + } + }, + "presentation_constraints": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "pressure": { + "type": "object", + "required": [ + "pressure_id", + "label", + "intensity" + ], + "additionalProperties": false, + "properties": { + "pressure_id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "inferred_variable_map": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/inferred_state_variable" + } + }, + "inferred_state_variable": { + "type": "object", + "required": [ + "observed_activation", + "attributed_disposition", + "confidence" + ], + "additionalProperties": false, + "properties": { + "observed_activation": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "attributed_disposition": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "event_exchange_turn": { + "type": "object", + "required": [ + "speaker_id", + "utterance_summary", + "dialogue_function" + ], + "additionalProperties": false, + "properties": { + "speaker_id": { + "type": "string" + }, + "utterance_summary": { + "type": "string" + }, + "dialogue_function": { + "type": "string" + }, + "observable_cues": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "event_private_interpretation": { + "type": "object", + "required": [ + "agent_id", + "interpretation", + "confidence" + ], + "additionalProperties": false, + "properties": { + "agent_id": { + "type": "string" + }, + "interpretation": { + "type": "string" + }, + "attributed_motive": { + "type": "string" + }, + "appraisal_tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "event_effect": { + "type": "object", + "required": [ + "agent_id", + "summary" + ], + "additionalProperties": false, + "properties": { + "agent_id": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "state_deltas": { + "type": "array", + "items": { + "$ref": "#/$defs/state_delta" + } + }, + "new_belief_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "memory_update_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "state_delta": { + "type": "object", + "required": [ + "path", + "delta" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "delta": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + } + } + } +} diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs new file mode 100644 index 00000000..fb283e97 --- /dev/null +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +namespace GameCult.Networking +{ + /// + /// Optional metadata attached when CultNet emits a document message. + /// + public sealed class CultNetDocumentMessageOptions + { + public string? StoredAt { get; set; } + public string? SourceRuntimeId { get; set; } + public string? SourceAgentId { get; set; } + public string? SourceRole { get; set; } + public string[]? Tags { get; set; } + } + + /// + /// Maps a concrete CultCache entry type to a shared CultNet document contract. + /// + public sealed class CultNetDocumentBinding + { + internal CultNetDocumentBinding( + Type entryType, + string documentType, + string? payloadSchemaVersion, + Func documentKeySelector, + Func payloadSerializer, + Func payloadDeserializer) + { + EntryType = entryType ?? throw new ArgumentNullException(nameof(entryType)); + DocumentType = string.IsNullOrWhiteSpace(documentType) + ? throw new ArgumentException("DocumentType must be non-empty.", nameof(documentType)) + : documentType; + PayloadSchemaVersion = payloadSchemaVersion; + DocumentKeySelector = documentKeySelector ?? throw new ArgumentNullException(nameof(documentKeySelector)); + PayloadSerializer = payloadSerializer ?? throw new ArgumentNullException(nameof(payloadSerializer)); + PayloadDeserializer = payloadDeserializer ?? throw new ArgumentNullException(nameof(payloadDeserializer)); + } + + public Type EntryType { get; } + public string DocumentType { get; } + public string? PayloadSchemaVersion { get; } + public Func DocumentKeySelector { get; } + public Func PayloadSerializer { get; } + public Func PayloadDeserializer { get; } + + public static CultNetDocumentBinding ForEntry( + string documentType, + string? payloadSchemaVersion = null, + Func? documentKeySelector = null, + Func? payloadSerializer = null, + Func? payloadDeserializer = null) + where T : DatabaseEntry + { + return new CultNetDocumentBinding( + typeof(T), + documentType, + payloadSchemaVersion, + entry => + { + if (entry is not T typedEntry) + { + throw new InvalidOperationException( + $"Document binding for {typeof(T).Name} received {entry.GetType().Name}."); + } + + return documentKeySelector != null + ? documentKeySelector(typedEntry) + : typedEntry.ID.ToString("D", CultureInfo.InvariantCulture); + }, + entry => + { + if (entry is not T typedEntry) + { + throw new InvalidOperationException( + $"Document binding for {typeof(T).Name} received {entry.GetType().Name}."); + } + + return payloadSerializer != null + ? payloadSerializer(typedEntry) + : CultCacheEnvelopeSerialization.SerializePayload(typedEntry); + }, + payload => payloadDeserializer != null + ? payloadDeserializer(payload) + : CultCacheEnvelopeSerialization.DeserializePayload(payload)); + } + } + + /// + /// Creates and applies CultNet raw document/snapshot messages for bound CultCache entry types. + /// + public sealed class CultNetDocumentRegistry + { + private readonly Dictionary _bindingsByDocumentType = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _bindingsByEntryType = + new Dictionary(); + + public CultNetDocumentRegistry(IEnumerable? bindings = null) + { + if (bindings == null) + { + return; + } + + foreach (var binding in bindings) + { + Register(binding); + } + } + + public CultNetDocumentRegistry Register(CultNetDocumentBinding binding) + { + if (binding == null) throw new ArgumentNullException(nameof(binding)); + _bindingsByDocumentType[binding.DocumentType] = binding; + _bindingsByEntryType[binding.EntryType] = binding; + return this; + } + + public CultNetDocumentBinding? GetByDocumentType(string documentType) + { + if (documentType == null) throw new ArgumentNullException(nameof(documentType)); + return _bindingsByDocumentType.TryGetValue(documentType, out var binding) ? binding : null; + } + + public CultNetDocumentBinding? GetByEntryType(Type entryType) + { + if (entryType == null) throw new ArgumentNullException(nameof(entryType)); + return _bindingsByEntryType.TryGetValue(entryType, out var binding) ? binding : null; + } + + public CultNetDocumentDeleteMessage CreateDocumentDeleteMessage( + string messageId, + string documentType, + string documentKey) + { + return new CultNetDocumentDeleteMessage + { + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + DocumentType = RequireNonEmpty(documentType, nameof(documentType)), + DocumentKey = RequireNonEmpty(documentKey, nameof(documentKey)) + }; + } + + public CultNetDocumentPutRawMessage CreateRawDocumentPutMessage( + string messageId, + DatabaseEntry entry, + CultNetDocumentMessageOptions? options = null) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + var binding = RequireBinding(entry.GetType()); + var storedAt = ResolveStoredAt(options); + var documentKey = binding.DocumentKeySelector(entry); + + return new CultNetDocumentPutRawMessage + { + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + Document = CreateRawDocumentRecord(binding, entry, documentKey, storedAt, options) + }; + } + + public CultNetDocumentPutRawMessage CreateRawDocumentPutMessageFromEnvelope( + string messageId, + CultCacheEnvelope envelope) + { + if (envelope == null) throw new ArgumentNullException(nameof(envelope)); + var binding = RequireBinding(envelope.Type); + + return new CultNetDocumentPutRawMessage + { + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + Document = new CultNetRawDocumentRecord + { + DocumentType = envelope.Type, + DocumentKey = envelope.Key, + StoredAt = envelope.StoredAt, + PayloadSchemaVersion = binding.PayloadSchemaVersion, + PayloadEncoding = "messagepack", + Payload = envelope.Payload.ToArray() + } + }; + } + + public CultNetSnapshotRequestMessage CreateSnapshotRequest( + string messageId, + IEnumerable? documentTypes = null, + IEnumerable? documentKeys = null) + { + return new CultNetSnapshotRequestMessage + { + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + DocumentTypes = documentTypes?.ToArray(), + DocumentKeys = documentKeys?.ToArray() + }; + } + + public CultNetSnapshotResponseRawMessage CreateRawSnapshotResponse( + CultCache cache, + string messageId, + CultNetSnapshotRequestMessage? filter = null, + CultNetDocumentMessageOptions? options = null) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + + var requestedDocumentTypes = filter?.DocumentTypes != null + ? new HashSet(filter.DocumentTypes, StringComparer.Ordinal) + : null; + var requestedDocumentKeys = filter?.DocumentKeys != null + ? new HashSet(filter.DocumentKeys, StringComparer.Ordinal) + : null; + var storedAt = ResolveStoredAt(options); + + var documents = new List(); + foreach (var entry in cache.AllEntries) + { + var binding = GetByEntryType(entry.GetType()); + if (binding == null) + { + continue; + } + + if (requestedDocumentTypes != null && !requestedDocumentTypes.Contains(binding.DocumentType)) + { + continue; + } + + var documentKey = binding.DocumentKeySelector(entry); + if (requestedDocumentKeys != null && !requestedDocumentKeys.Contains(documentKey)) + { + continue; + } + + documents.Add(CreateRawDocumentRecord( + binding, + entry, + documentKey, + storedAt, + options)); + } + + return new CultNetSnapshotResponseRawMessage + { + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + Documents = documents.ToArray() + }; + } + + public async Task ApplyRawDocumentPutMessageAsync( + CultCache cache, + CultNetDocumentPutRawMessage message) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (message == null) throw new ArgumentNullException(nameof(message)); + if (message.Document == null) throw new ArgumentException("CultNet raw document message is missing its document payload.", nameof(message)); + + var binding = RequireBinding(message.Document.DocumentType); + ValidateRawDocumentRecord(message.Document); + + var entry = binding.PayloadDeserializer(message.Document.Payload); + if (!binding.EntryType.IsInstanceOfType(entry)) + { + throw new InvalidOperationException( + $"CultNet raw document type \"{message.Document.DocumentType}\" expected {binding.EntryType.Name} but decoded {entry.GetType().Name}."); + } + + var expectedKey = binding.DocumentKeySelector(entry); + if (!string.Equals(expectedKey, message.Document.DocumentKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"CultNet raw document key mismatch for \"{message.Document.DocumentType}\": payload resolved to \"{expectedKey}\" but wire metadata said \"{message.Document.DocumentKey}\"."); + } + + await cache.AddAsync(entry); + return entry; + } + + public async Task ApplyRawDocumentPutMessageAsync( + CultCache cache, + CultNetDocumentPutRawMessage message) + where T : DatabaseEntry + { + var entry = await ApplyRawDocumentPutMessageAsync(cache, message); + if (entry is not T typedEntry) + { + throw new InvalidOperationException( + $"CultNet raw document message resolved to {entry.GetType().Name}, not {typeof(T).Name}."); + } + + return typedEntry; + } + + public async Task> ApplyRawSnapshotResponseAsync( + CultCache cache, + CultNetSnapshotResponseRawMessage response) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (response == null) throw new ArgumentNullException(nameof(response)); + + var applied = new List(response.Documents.Length); + foreach (var document in response.Documents) + { + var entry = await ApplyRawDocumentPutMessageAsync( + cache, + new CultNetDocumentPutRawMessage + { + MessageId = response.MessageId, + Document = document + }); + applied.Add(entry); + } + + return applied; + } + + public async Task> ApplyRawSnapshotResponseAsync( + CultCache cache, + CultNetSnapshotResponseRawMessage response) + where T : DatabaseEntry + { + var applied = await ApplyRawSnapshotResponseAsync(cache, response); + return applied.OfType().ToArray(); + } + + private CultNetRawDocumentRecord CreateRawDocumentRecord( + CultNetDocumentBinding binding, + DatabaseEntry entry, + string documentKey, + string storedAt, + CultNetDocumentMessageOptions? options) + { + var envelope = new CultCacheEnvelope + { + Key = documentKey, + Type = binding.DocumentType, + StoredAt = storedAt, + Payload = binding.PayloadSerializer(entry) + }; + + return new CultNetRawDocumentRecord + { + DocumentType = envelope.Type, + DocumentKey = envelope.Key, + StoredAt = envelope.StoredAt, + PayloadSchemaVersion = binding.PayloadSchemaVersion, + PayloadEncoding = "messagepack", + Payload = envelope.Payload, + SourceRuntimeId = options?.SourceRuntimeId, + SourceAgentId = options?.SourceAgentId, + SourceRole = options?.SourceRole, + Tags = options?.Tags + }; + } + + private CultNetDocumentBinding RequireBinding(Type entryType) + { + return GetByEntryType(entryType) + ?? throw new InvalidOperationException( + $"No CultNet document binding is registered for {entryType.FullName}."); + } + + private CultNetDocumentBinding RequireBinding(string documentType) + { + return GetByDocumentType(documentType) + ?? throw new InvalidOperationException( + $"No CultNet document binding is registered for \"{documentType}\"."); + } + + private static void ValidateRawDocumentRecord(CultNetRawDocumentRecord document) + { + if (document.PayloadEncoding != "messagepack") + { + throw new InvalidOperationException( + $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{document.PayloadEncoding}\"."); + } + + if (document.Payload == null || document.Payload.Length == 0) + { + throw new InvalidOperationException("CultNet raw document payload must be non-empty."); + } + + RequireNonEmpty(document.DocumentType, nameof(document.DocumentType)); + RequireNonEmpty(document.DocumentKey, nameof(document.DocumentKey)); + RequireNonEmpty(document.StoredAt, nameof(document.StoredAt)); + } + + private static string ResolveStoredAt(CultNetDocumentMessageOptions? options) + { + return !string.IsNullOrWhiteSpace(options?.StoredAt) + ? options!.StoredAt! + : DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); + } + + private static string RequireNonEmpty(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value must be non-empty.", paramName); + } + + return value; + } + } +} diff --git a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs new file mode 100644 index 00000000..c32b395e --- /dev/null +++ b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs @@ -0,0 +1,63 @@ +using System; +using MessagePack; + +namespace GameCult.Networking +{ + /// + /// Serializes and deserializes the explicit modern CultNet schema-v0 message family. + /// + public static class CultNetSchemaMessageSerialization + { + public static readonly MessagePackSerializerOptions Options = + MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData); + + public static byte[] Serialize(T message) where T : ICultNetSchemaMessage + { + if (message == null) throw new ArgumentNullException(nameof(message)); + return MessagePackSerializer.Serialize(message, Options); + } + + public static T Deserialize(byte[] payload) where T : ICultNetSchemaMessage + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + return MessagePackSerializer.Deserialize(payload, Options); + } + + public static ICultNetSchemaMessage Deserialize(byte[] payload) + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + + var header = MessagePackSerializer.Deserialize(payload, Options); + if (header == null || string.IsNullOrWhiteSpace(header.SchemaVersion)) + { + throw new MessagePackSerializationException("CultNet schema-v0 payload is missing schemaVersion."); + } + + return header.SchemaVersion switch + { + CultNetSchemaVersions.Hello => Deserialize(payload), + CultNetSchemaVersions.Login => Deserialize(payload), + CultNetSchemaVersions.Register => Deserialize(payload), + CultNetSchemaVersions.Verify => Deserialize(payload), + CultNetSchemaVersions.LoginSuccess => Deserialize(payload), + CultNetSchemaVersions.Error => Deserialize(payload), + CultNetSchemaVersions.SampleChangeName => Deserialize(payload), + CultNetSchemaVersions.SampleChat => Deserialize(payload), + CultNetSchemaVersions.DocumentDelete => Deserialize(payload), + CultNetSchemaVersions.DocumentPutRaw => Deserialize(payload), + CultNetSchemaVersions.SnapshotRequest => Deserialize(payload), + CultNetSchemaVersions.SnapshotResponseRaw => Deserialize(payload), + CultNetSchemaVersions.SchemaCatalogRequest => Deserialize(payload), + CultNetSchemaVersions.SchemaCatalogResponse => Deserialize(payload), + _ => throw new MessagePackSerializationException( + $"Unsupported CultNet schema-v0 message \"{header.SchemaVersion}\".") + }; + } + + [MessagePackObject] + internal sealed class CultNetSchemaHeader + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty; + } + } +} diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs new file mode 100644 index 00000000..3323c92a --- /dev/null +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -0,0 +1,193 @@ +using System; +using MessagePack; + +namespace GameCult.Networking +{ + /// + /// Shared wire-contract identifiers understood by the CultNet family. + /// + public static class CultNetWireContracts + { + public const string SchemaV0 = "cultnet.schema.v0"; + public const string GameCultNetworkingV0 = "gamecult.networking.v0"; + } + + /// + /// Canonical schema-version strings for the modern CultNet message family. + /// + public static class CultNetSchemaVersions + { + public const string Hello = "cultnet.hello.v0"; + public const string Login = "cultnet.login.v0"; + public const string Register = "cultnet.register.v0"; + public const string Verify = "cultnet.verify.v0"; + public const string LoginSuccess = "cultnet.login_success.v0"; + public const string Error = "cultnet.error.v0"; + public const string SampleChangeName = "cultnet.sample.change_name.v0"; + public const string SampleChat = "cultnet.sample.chat.v0"; + public const string DocumentDelete = "cultnet.document_delete.v0"; + public const string DocumentPutRaw = "cultnet.document_put_raw.v0"; + public const string SnapshotRequest = "cultnet.snapshot_request.v0"; + public const string SnapshotResponseRaw = "cultnet.snapshot_response_raw.v0"; + public const string SchemaCatalogRequest = "cultnet.schema_catalog_request.v0"; + public const string SchemaCatalogResponse = "cultnet.schema_catalog_response.v0"; + } + + /// + /// Marker interface for the explicit modern CultNet schema-v0 message family. + /// + public interface ICultNetSchemaMessage + { + string SchemaVersion { get; set; } + } + + [MessagePackObject] + public class CultNetHelloMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.Hello; + [Key("runtimeId")] public string RuntimeId { get; set; } = string.Empty; + [Key("runtimeKind")] public string RuntimeKind { get; set; } = string.Empty; + [Key("agentId")] public string? AgentId { get; set; } + [Key("role")] public string? Role { get; set; } + [Key("displayName")] public string? DisplayName { get; set; } + [Key("supportedDocumentTypes")] public string[]? SupportedDocumentTypes { get; set; } + [Key("supportedMessageVersions")] public string[]? SupportedMessageVersions { get; set; } + [Key("supportsSchemaCatalog")] public bool SupportsSchemaCatalog { get; set; } + } + + [MessagePackObject] + public class CultNetLoginMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.Login; + [Key("nonce")] public string Nonce { get; set; } = string.Empty; + [Key("auth")] public string Auth { get; set; } = string.Empty; + [Key("password")] public string Password { get; set; } = string.Empty; + } + + [MessagePackObject] + public class CultNetRegisterMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.Register; + [Key("nonce")] public string Nonce { get; set; } = string.Empty; + [Key("email")] public string Email { get; set; } = string.Empty; + [Key("password")] public string Password { get; set; } = string.Empty; + [Key("name")] public string Name { get; set; } = string.Empty; + } + + [MessagePackObject] + public class CultNetVerifyMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.Verify; + [Key("nonce")] public string Nonce { get; set; } = string.Empty; + [Key("session")] public string Session { get; set; } = string.Empty; + } + + [MessagePackObject] + public class CultNetLoginSuccessMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.LoginSuccess; + [Key("nonce")] public string Nonce { get; set; } = string.Empty; + [Key("session")] public string Session { get; set; } = string.Empty; + } + + [MessagePackObject] + public class CultNetErrorMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.Error; + [Key("error")] public string Error { get; set; } = string.Empty; + } + + [MessagePackObject] + public class CultNetSampleChangeNameMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SampleChangeName; + [Key("name")] public string Name { get; set; } = string.Empty; + } + + [MessagePackObject] + public class CultNetSampleChatMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SampleChat; + [Key("text")] public string Text { get; set; } = string.Empty; + } + + [MessagePackObject] + public class CultNetRawDocumentRecord + { + [Key("documentType")] public string DocumentType { get; set; } = string.Empty; + [Key("documentKey")] public string DocumentKey { get; set; } = string.Empty; + [Key("storedAt")] public string StoredAt { get; set; } = string.Empty; + [Key("payloadSchemaVersion")] public string? PayloadSchemaVersion { get; set; } + [Key("payloadEncoding")] public string PayloadEncoding { get; set; } = "messagepack"; + [Key("payload")] public byte[] Payload { get; set; } = Array.Empty(); + [Key("sourceRuntimeId")] public string? SourceRuntimeId { get; set; } + [Key("sourceAgentId")] public string? SourceAgentId { get; set; } + [Key("sourceRole")] public string? SourceRole { get; set; } + [Key("tags")] public string[]? Tags { get; set; } + } + + [MessagePackObject] + public class CultNetDocumentDeleteMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.DocumentDelete; + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + [Key("documentType")] public string DocumentType { get; set; } = string.Empty; + [Key("documentKey")] public string DocumentKey { get; set; } = string.Empty; + } + + [MessagePackObject] + public class CultNetDocumentPutRawMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.DocumentPutRaw; + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + [Key("document")] public CultNetRawDocumentRecord Document { get; set; } = new CultNetRawDocumentRecord(); + } + + [MessagePackObject] + public class CultNetSnapshotRequestMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotRequest; + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + [Key("documentTypes")] public string[]? DocumentTypes { get; set; } + [Key("documentKeys")] public string[]? DocumentKeys { get; set; } + } + + [MessagePackObject] + public class CultNetSnapshotResponseRawMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotResponseRaw; + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + [Key("documents")] public CultNetRawDocumentRecord[] Documents { get; set; } = Array.Empty(); + } + + [MessagePackObject] + public class CultNetSchemaDescriptor + { + [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; + [Key("kind")] public string Kind { get; set; } = string.Empty; + [Key("schemaVersion")] public string? SchemaVersion { get; set; } + [Key("documentType")] public string? DocumentType { get; set; } + [Key("title")] public string? Title { get; set; } + [Key("wireContracts")] public string[] WireContracts { get; set; } = Array.Empty(); + [Key("contentHash")] public string ContentHash { get; set; } = string.Empty; + [Key("schemaJson")] public string? SchemaJson { get; set; } + } + + [MessagePackObject] + public class CultNetSchemaCatalogRequestMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SchemaCatalogRequest; + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + [Key("includeSchemaJson")] public bool IncludeSchemaJson { get; set; } + [Key("schemaIds")] public string[]? SchemaIds { get; set; } + [Key("kinds")] public string[]? Kinds { get; set; } + } + + [MessagePackObject] + public class CultNetSchemaCatalogResponseMessage : ICultNetSchemaMessage + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SchemaCatalogResponse; + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + [Key("schemas")] public CultNetSchemaDescriptor[] Schemas { get; set; } = Array.Empty(); + } +} diff --git a/src/GameCult.Networking/CultNetSchemaRegistry.cs b/src/GameCult.Networking/CultNetSchemaRegistry.cs new file mode 100644 index 00000000..21a96ec5 --- /dev/null +++ b/src/GameCult.Networking/CultNetSchemaRegistry.cs @@ -0,0 +1,368 @@ +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); + + public CultNetSchemaRegistry(IEnumerable? entries = null) + { + if (entries == null) + { + return; + } + + foreach (var entry in entries) + { + Register(entry); + } + } + + 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; + } + + 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; + } + + 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(); + } + + 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) + }; + } + + public static CultNetSchemaRegistry CreateBuiltIn() + { + return new CultNetSchemaRegistry(BuiltInSchemaManifest.Select(LoadEmbeddedRegistration)); + } + + private static readonly Lazy BuiltInRegistry = + new Lazy(CreateBuiltIn); + + 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.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.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.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 } + } + }; + } + + /// + /// One exchange-safe schema registration. + /// + public sealed class CultNetSchemaRegistration + { + public string SchemaId { get; set; } = string.Empty; + public string Kind { get; set; } = string.Empty; + public string[] WireContracts { get; set; } = Array.Empty(); + public string SchemaJson { get; set; } = string.Empty; + public string? SchemaVersion { get; set; } + public string? DocumentType { get; set; } + public string? Title { get; set; } + } +} diff --git a/src/GameCult.Networking/GameCult.Networking.csproj b/src/GameCult.Networking/GameCult.Networking.csproj index 11d6882d..682d3ad4 100644 --- a/src/GameCult.Networking/GameCult.Networking.csproj +++ b/src/GameCult.Networking/GameCult.Networking.csproj @@ -6,9 +6,18 @@ + + + - \ No newline at end of file + + + + + diff --git a/src/GameCult.Networking/NetPeerExtensions.cs b/src/GameCult.Networking/NetPeerExtensions.cs index 0d2d1130..4b4470c8 100644 --- a/src/GameCult.Networking/NetPeerExtensions.cs +++ b/src/GameCult.Networking/NetPeerExtensions.cs @@ -18,5 +18,16 @@ namespace GameCult.Networking { peer.Send(MessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered); } + + /// + /// Serializes and sends a modern CultNet schema-v0 message using reliable ordered delivery. + /// + /// The concrete schema-v0 message type. + /// The peer to send through. + /// The schema-v0 message to serialize and send. + public static void SendCultNet(this NetPeer peer, T message) where T : ICultNetSchemaMessage + { + peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered); + } } } diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index 30c3a31d..27985ad2 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -17,6 +17,8 @@ The library currently includes: - encrypted login, register, and verify flows - signed session-token generation and validation - `PlayerData` integration with `CultCache` +- explicit schema discovery for shared contracts +- a raw MessagePack document/snapshot lane for bit-compatible neighbors - sample application payloads under `Samples/` The library is focused on the built-in authentication and session flows in this repository. @@ -31,6 +33,27 @@ Keep the distinction clean: - if multiple apps share a message contract, they should be able to talk directly without bespoke translation sludge +## Wire Contracts + +`GameCult.Networking` now speaks two explicit wire contracts: + +- `gamecult.networking.v0` + - the legacy union-based auth/session/sample message surface +- `cultnet.schema.v0` + - the newer schema-first contract family for discovery, raw document puts, + raw snapshot replication, and cross-runtime shared-state work + +There is no inbound autodetect priesthood here. Pick the contract on purpose, +keep the schema stable, and let peers discover what they can exchange before +they start lobbing bytes at each other. + +Modern schema-v0 helpers live in: + +- `CultNetSchemaMessageSerialization` +- `CultNetSchemaRegistry` +- `CultNetDocumentRegistry` +- `NetPeerExtensions.SendCultNet(...)` + ## Main Types - `Message`: base type for all wire messages @@ -49,6 +72,20 @@ Sample payloads: Those now live under `Samples/` to make it obvious they are example application messages, not the entire meaning of the library. +Schema-v0 message families include: + +- `CultNetHelloMessage` +- `CultNetSchemaCatalogRequestMessage` +- `CultNetSchemaCatalogResponseMessage` +- `CultNetDocumentPutRawMessage` +- `CultNetDocumentDeleteMessage` +- `CultNetSnapshotRequestMessage` +- `CultNetSnapshotResponseRawMessage` + +The raw document/snapshot lane is meant for neighbors that already share the +same payload schema and MessagePack semantics. It carries exact payload bytes +plus explicit type metadata; it does not guess what a blob "probably" means. + ## Authentication Model The built-in flow is: @@ -213,3 +250,10 @@ anything else, treat message tags and field keys like CultCache schema: - stable - shared - boring in exactly the useful way + +For the newer schema-v0 lane, the equivalent rule is: + +- keep canonical JSON Schema files in sync +- keep payload codecs in sync +- advertise supported contracts through schema discovery +- do not rely on implicit runtime magic to decide what a peer meant diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 887052dc..d741809c 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -2,6 +2,9 @@ using System; using System.Reflection; using System.Text; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; using NUnit.Framework; namespace GameCult.Networking.Tests @@ -161,6 +164,112 @@ namespace GameCult.Networking.Tests .With.InnerException.InstanceOf()); } + [Test] + public void CultNetSchemaMessageSerialization_RoundTrips_RawSnapshotResponse() + { + var message = new CultNetSnapshotResponseRawMessage + { + MessageId = "snapshot-1", + Documents = + [ + new CultNetRawDocumentRecord + { + DocumentType = "ghostlight.agent-state", + DocumentKey = "world/main", + StoredAt = "2026-05-06T12:34:56.0000000+00:00", + PayloadSchemaVersion = "ghostlight.agent_state.v0", + PayloadEncoding = "messagepack", + Payload = [0x91, 0xA3, 0x66, 0x6F, 0x6F], + SourceRuntimeId = "voidbot", + SourceAgentId = "void", + SourceRole = "herald", + Tags = ["swarm", "dream"] + } + ] + }; + + var payload = CultNetSchemaMessageSerialization.Serialize(message); + var roundTrip = (CultNetSnapshotResponseRawMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(roundTrip.MessageId, Is.EqualTo("snapshot-1")); + Assert.That(roundTrip.Documents, Has.Length.EqualTo(1)); + Assert.That(roundTrip.Documents[0].DocumentType, Is.EqualTo("ghostlight.agent-state")); + Assert.That(roundTrip.Documents[0].PayloadEncoding, Is.EqualTo("messagepack")); + Assert.That(roundTrip.Documents[0].Payload, Is.EqualTo(message.Documents[0].Payload)); + Assert.That(roundTrip.Documents[0].Tags, Is.EqualTo(["swarm", "dream"])); + } + + [Test] + public async Task CultNetDocumentRegistry_RawSnapshotReplication_PreservesPayloadBytes() + { + var sourceCache = new CultCache(); + var targetCache = new CultCache(); + var registry = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForEntry( + "gamecult.player_data", + payloadSchemaVersion: "gamecult.player_data.v0", + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + + var sourceEntry = new PlayerData + { + ID = Guid.NewGuid(), + Email = "cult@example.test", + PasswordHash = "not-a-real-hash", + Username = "CultGhost" + }; + + await sourceCache.AddAsync(sourceEntry); + + var expectedPayload = SerializePlayerDataPayload(sourceEntry); + var request = registry.CreateSnapshotRequest( + "request-1", + documentTypes: ["gamecult.player_data"], + documentKeys: [sourceEntry.ID.ToString("D")]); + var response = registry.CreateRawSnapshotResponse(sourceCache, "snapshot-1", request); + var serializedResponse = CultNetSchemaMessageSerialization.Serialize(response); + var roundTrip = (CultNetSnapshotResponseRawMessage)CultNetSchemaMessageSerialization.Deserialize(serializedResponse); + + Assert.That(roundTrip.Documents, Has.Length.EqualTo(1)); + Assert.That(roundTrip.Documents[0].Payload, Is.EqualTo(expectedPayload)); + + await registry.ApplyRawSnapshotResponseAsync(targetCache, roundTrip); + var replicated = targetCache.Get(sourceEntry.ID); + + Assert.That(replicated, Is.Not.Null); + Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload)); + } + + [Test] + public void CultNetSchemaRegistry_BuiltInCatalog_AdvertisesRawLane_AndSharedGhostlightContract() + { + var response = CultNetSchemaRegistry.BuiltIn.CreateCatalogResponse( + new CultNetSchemaCatalogRequestMessage + { + MessageId = "catalog-raw", + IncludeSchemaJson = true + }); + + var rawPut = Array.Find(response.Schemas, + schema => schema.SchemaVersion == CultNetSchemaVersions.DocumentPutRaw); + var rawSnapshot = Array.Find(response.Schemas, + schema => schema.SchemaVersion == CultNetSchemaVersions.SnapshotResponseRaw); + var ghostlight = Array.Find(response.Schemas, + schema => schema.SchemaVersion == "ghostlight.agent_state.v0"); + + Assert.That(rawPut, Is.Not.Null); + Assert.That(rawPut!.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0])); + Assert.That(rawPut.SchemaJson, Does.Contain("cultnet.document_put_raw.v0")); + Assert.That(rawPut.ContentHash, Has.Length.EqualTo(64)); + + Assert.That(rawSnapshot, Is.Not.Null); + Assert.That(rawSnapshot!.SchemaJson, Does.Contain("cultnet.snapshot_response_raw.v0")); + + Assert.That(ghostlight, Is.Not.Null); + Assert.That(ghostlight!.DocumentType, Is.EqualTo("ghostlight.agent-state")); + Assert.That(ghostlight.Kind, Is.EqualTo("document_payload")); + } + private sealed class EnvironmentVariableScope : IDisposable { private readonly (string Name, string? Value)[] _originalValues; @@ -183,5 +292,37 @@ namespace GameCult.Networking.Tests } } } + + [MessagePack.MessagePackObject] + public sealed class PlayerDataPayload + { + [MessagePack.Key(0)] public Guid Id { get; set; } + [MessagePack.Key(1)] public string Email { get; set; } = string.Empty; + [MessagePack.Key(2)] public string PasswordHash { get; set; } = string.Empty; + [MessagePack.Key(3)] public string Username { get; set; } = string.Empty; + } + + private static byte[] SerializePlayerDataPayload(PlayerData entry) + { + return MessagePack.MessagePackSerializer.Serialize(new PlayerDataPayload + { + Id = entry.ID, + Email = entry.Email, + PasswordHash = entry.PasswordHash, + Username = entry.Username + }); + } + + private static PlayerData DeserializePlayerDataPayload(byte[] payload) + { + var decoded = MessagePack.MessagePackSerializer.Deserialize(payload); + return new PlayerData + { + ID = decoded.Id, + Email = decoded.Email, + PasswordHash = decoded.PasswordHash, + Username = decoded.Username + }; + } } }