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
+ };
+ }
}
}