diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs
index 8fe2dfb9..16178387 100644
--- a/src/GameCult.Mesh/CultMesh.cs
+++ b/src/GameCult.Mesh/CultMesh.cs
@@ -196,6 +196,17 @@ namespace GameCult.Mesh
return new CultMeshVerseCatalog();
}
+ ///
+ /// Attaches Verse discovery responses to a CultMesh node.
+ ///
+ public static CultMeshVerseDiscoveryServer ServeVerseCatalog(
+ CultMeshNode node,
+ CultMeshVerseCatalog catalog)
+ {
+ if (node == null) throw new ArgumentNullException(nameof(node));
+ return new CultMeshVerseDiscoveryServer(node.Server, catalog);
+ }
+
///
/// Creates a CultNet client configured for CultMesh use.
///
diff --git a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs
new file mode 100644
index 00000000..bbe2086f
--- /dev/null
+++ b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using GameCult.Networking;
+using LiteNetLib;
+
+namespace GameCult.Mesh
+{
+ ///
+ /// Conversion helpers for CultMesh Verse wire messages.
+ ///
+ public static class CultMeshVerseMessages
+ {
+ ///
+ /// Converts a local Verse descriptor to its schema-v0 wire shape.
+ ///
+ public static CultMeshVerseDescriptorMessage ToMessage(this CultMeshVerseDescriptor verse)
+ {
+ if (verse == null) throw new ArgumentNullException(nameof(verse));
+ return new CultMeshVerseDescriptorMessage
+ {
+ VerseId = verse.VerseId,
+ DisplayName = verse.DisplayName,
+ AuthorityModel = verse.AuthorityModel.ToString(),
+ Compatibility = new CultMeshVerseCompatibilityMessage
+ {
+ TransportVersion = verse.Compatibility.TransportVersion,
+ RulesHash = verse.Compatibility.RulesHash,
+ CompatibleVerseIds = verse.Compatibility.CompatibleVerseIds.ToArray(),
+ RequiredPluginIds = verse.Compatibility.RequiredPluginIds.ToArray(),
+ OptionalPluginIds = verse.Compatibility.OptionalPluginIds.ToArray()
+ },
+ DiscoveryEndpoints = verse.DiscoveryEndpoints.ToArray(),
+ AuthorityRuntimeIds = verse.AuthorityRuntimeIds.ToArray(),
+ ParentVerseId = verse.ParentVerseId,
+ Description = verse.Description
+ };
+ }
+
+ ///
+ /// Converts a schema-v0 Verse descriptor to the local public API shape.
+ ///
+ public static CultMeshVerseDescriptor ToVerseDescriptor(this CultMeshVerseDescriptorMessage message)
+ {
+ if (message == null) throw new ArgumentNullException(nameof(message));
+ var authorityModel = Enum.TryParse(
+ message.AuthorityModel,
+ ignoreCase: true,
+ out var parsed)
+ ? parsed
+ : CultMeshVerseAuthorityModel.SubscribedOverlay;
+ return new CultMeshVerseDescriptor(
+ message.VerseId,
+ message.DisplayName,
+ authorityModel,
+ new CultMeshVerseCompatibility(
+ message.Compatibility.TransportVersion,
+ message.Compatibility.RulesHash,
+ message.Compatibility.CompatibleVerseIds,
+ message.Compatibility.RequiredPluginIds,
+ message.Compatibility.OptionalPluginIds),
+ message.DiscoveryEndpoints,
+ message.AuthorityRuntimeIds,
+ message.ParentVerseId,
+ message.Description);
+ }
+ }
+
+ ///
+ /// Answers CultMesh Verse discovery requests from a local Verse catalog.
+ ///
+ public sealed class CultMeshVerseDiscoveryServer : IDisposable
+ {
+ private readonly Server _server;
+ private readonly CultMeshVerseCatalog _catalog;
+ private readonly Func _requestHandler;
+ private bool _disposed;
+
+ ///
+ /// Creates and attaches a Verse discovery bridge.
+ ///
+ public CultMeshVerseDiscoveryServer(Server server, CultMeshVerseCatalog catalog)
+ {
+ _server = server ?? throw new ArgumentNullException(nameof(server));
+ _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
+ _requestHandler = HandleRequestAsync;
+ _server.OnCultNet(_requestHandler);
+ }
+
+ ///
+ /// Creates a Verse catalog response for a request.
+ ///
+ public CultMeshVerseCatalogResponseMessage CreateResponse(CultMeshVerseCatalogRequestMessage request)
+ {
+ if (request == null) throw new ArgumentNullException(nameof(request));
+ var verseIds = request.VerseIds == null || request.VerseIds.Length == 0
+ ? null
+ : new HashSet(request.VerseIds, StringComparer.Ordinal);
+ var verses = _catalog.Verses
+ .Where(verse => verseIds == null || verseIds.Contains(verse.VerseId))
+ .Where(verse => string.IsNullOrWhiteSpace(request.TransportVersion) ||
+ string.Equals(verse.Compatibility.TransportVersion, request.TransportVersion, StringComparison.Ordinal))
+ .Select(verse => verse.ToMessage())
+ .ToArray();
+
+ return new CultMeshVerseCatalogResponseMessage
+ {
+ MessageId = string.IsNullOrWhiteSpace(request.MessageId)
+ ? Guid.NewGuid().ToString("N")
+ : request.MessageId,
+ Verses = verses
+ };
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _server.RemoveCultNetMessageListener(_requestHandler);
+ }
+
+ private Task HandleRequestAsync(CultMeshVerseCatalogRequestMessage request, NetPeer peer)
+ {
+ peer.SendCultNet(CreateResponse(request));
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md
index d9dc240b..d5e1c8bc 100644
--- a/src/GameCult.Mesh/README.md
+++ b/src/GameCult.Mesh/README.md
@@ -62,8 +62,14 @@ var aetheria = new CultMeshVerseDescriptor(
using var catalog = CultMesh.CreateVerseCatalog();
catalog.Upsert(aetheria);
+
+using var discovery = CultMesh.ServeVerseCatalog(node, catalog);
```
+Peers can request Verse catalogs over schema-v0 with
+`cultmesh.verse_catalog_request.v0` and receive transport-filtered
+`cultmesh.verse_catalog_response.v0` descriptors.
+
## Client-Side Prediction
CultMesh lets a runtime declare which input documents it may author
diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md
index 21e22ae3..6b5876d6 100644
--- a/src/GameCult.Mesh/docs/public-api.md
+++ b/src/GameCult.Mesh/docs/public-api.md
@@ -42,6 +42,10 @@ known authority runtimes, optional parent Verse id, and plugin requirements.
`CultMeshVerseCatalog` is the local reactive catalog for discovered Verses.
It can publish discovery updates and find compatible transfer targets.
+`CultMesh.ServeVerseCatalog(node, catalog)` attaches schema-v0 Verse discovery
+responses to a node. The wire contracts are
+`cultmesh.verse_catalog_request.v0` and `cultmesh.verse_catalog_response.v0`.
+
### Shard Authority
Each shard has one primary writer for now. Non-primary writes are rejected or
diff --git a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md
index c19a13ef..1b24782f 100644
--- a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md
+++ b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md
@@ -69,6 +69,8 @@ Implemented:
replacement and cursor advancement
- CultMesh node option for file-backed authoritative shard logs with a default
`.cultmesh/shard-logs` path beside the cache file
+- schema-v0 Verse catalog request/response messages
+- CultMesh Verse discovery server bridge over the local Verse catalog
- client-authority scopes for locally predicted input documents
- predicted and reconciled database change kinds for client-side prediction
- simulation witness observations for mesh-side opinions about frame facts
@@ -121,7 +123,7 @@ Not implemented yet:
Add discovery fanout and operational polish:
-- add Verse discovery over the schema-v0 mesh lane
+- add peer-to-peer Verse discovery fetchers and gossip fanout
- add snapshot throttling and size limits for public mesh edges
- after that, add simulation-frame rollback/resimulation helpers around
predicted input streams
diff --git a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs
index 3faebce7..5b15313a 100644
--- a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs
+++ b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs
@@ -68,6 +68,8 @@ namespace GameCult.Networking
CultNetSchemaVersions.ShardLogResponse => Deserialize(payload),
CultNetSchemaVersions.SimulationObservation => Deserialize(payload),
CultNetSchemaVersions.SimulationConsensusCandidate => Deserialize(payload),
+ CultNetSchemaVersions.VerseCatalogRequest => Deserialize(payload),
+ CultNetSchemaVersions.VerseCatalogResponse => Deserialize(payload),
CultNetSchemaVersions.SchemaCatalogRequest => Deserialize(payload),
CultNetSchemaVersions.SchemaCatalogResponse => Deserialize(payload),
_ => throw new MessagePackSerializationException(
diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs
index e7849193..4d8726f3 100644
--- a/src/GameCult.Networking/CultNetSchemaMessages.cs
+++ b/src/GameCult.Networking/CultNetSchemaMessages.cs
@@ -115,6 +115,14 @@ namespace GameCult.Networking
/// simulation consensus candidate gossip contract identifier.
///
public const string SimulationConsensusCandidate = "cultnet.simulation_consensus_candidate.v0";
+ ///
+ /// CultMesh Verse catalog request contract identifier.
+ ///
+ public const string VerseCatalogRequest = "cultmesh.verse_catalog_request.v0";
+ ///
+ /// CultMesh Verse catalog response contract identifier.
+ ///
+ public const string VerseCatalogResponse = "cultmesh.verse_catalog_response.v0";
}
///
@@ -957,6 +965,118 @@ namespace GameCult.Networking
}
}
+ ///
+ /// Requests known CultMesh Verses from a peer.
+ ///
+ [MessagePackObject]
+ public class CultMeshVerseCatalogRequestMessage : ICultNetSchemaMessage
+ {
+ ///
+ /// Gets or sets the schema version.
+ ///
+ [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.VerseCatalogRequest;
+ ///
+ /// Gets or sets the request id.
+ ///
+ [Key("messageId")] public string MessageId { get; set; } = string.Empty;
+ ///
+ /// Gets or sets optional Verse ids to request.
+ ///
+ [Key("verseIds")] public string[]? VerseIds { get; set; }
+ ///
+ /// Gets or sets an optional transport compatibility filter.
+ ///
+ [Key("transportVersion")] public string? TransportVersion { get; set; }
+ }
+
+ ///
+ /// Returns known CultMesh Verses.
+ ///
+ [MessagePackObject]
+ public class CultMeshVerseCatalogResponseMessage : ICultNetSchemaMessage
+ {
+ ///
+ /// Gets or sets the schema version.
+ ///
+ [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.VerseCatalogResponse;
+ ///
+ /// Gets or sets the request id.
+ ///
+ [Key("messageId")] public string MessageId { get; set; } = string.Empty;
+ ///
+ /// Gets or sets discovered Verses.
+ ///
+ [Key("verses")] public CultMeshVerseDescriptorMessage[] Verses { get; set; } = Array.Empty();
+ }
+
+ ///
+ /// Wire representation of a CultMesh Verse.
+ ///
+ [MessagePackObject]
+ public class CultMeshVerseDescriptorMessage
+ {
+ ///
+ /// Gets or sets the stable Verse id.
+ ///
+ [Key("verseId")] public string VerseId { get; set; } = string.Empty;
+ ///
+ /// Gets or sets the display name.
+ ///
+ [Key("displayName")] public string DisplayName { get; set; } = string.Empty;
+ ///
+ /// Gets or sets the authority model name.
+ ///
+ [Key("authorityModel")] public string AuthorityModel { get; set; } = string.Empty;
+ ///
+ /// Gets or sets compatibility metadata.
+ ///
+ [Key("compatibility")] public CultMeshVerseCompatibilityMessage Compatibility { get; set; } = new CultMeshVerseCompatibilityMessage();
+ ///
+ /// Gets or sets endpoints that can answer discovery for this Verse.
+ ///
+ [Key("discoveryEndpoints")] public string[] DiscoveryEndpoints { get; set; } = Array.Empty();
+ ///
+ /// Gets or sets known authoritative runtime ids.
+ ///
+ [Key("authorityRuntimeIds")] public string[] AuthorityRuntimeIds { get; set; } = Array.Empty();
+ ///
+ /// Gets or sets the parent Verse id for overlays or branches.
+ ///
+ [Key("parentVerseId")] public string? ParentVerseId { get; set; }
+ ///
+ /// Gets or sets an optional public description.
+ ///
+ [Key("description")] public string? Description { get; set; }
+ }
+
+ ///
+ /// Wire representation of CultMesh Verse compatibility.
+ ///
+ [MessagePackObject]
+ public class CultMeshVerseCompatibilityMessage
+ {
+ ///
+ /// Gets or sets the CultMesh transport compatibility version.
+ ///
+ [Key("transportVersion")] public string TransportVersion { get; set; } = string.Empty;
+ ///
+ /// Gets or sets the stable rules hash.
+ ///
+ [Key("rulesHash")] public string RulesHash { get; set; } = string.Empty;
+ ///
+ /// Gets or sets Verse ids this Verse can transfer from or subscribe to.
+ ///
+ [Key("compatibleVerseIds")] public string[] CompatibleVerseIds { get; set; } = Array.Empty();
+ ///
+ /// Gets or sets required plugin ids.
+ ///
+ [Key("requiredPluginIds")] public string[] RequiredPluginIds { get; set; } = Array.Empty();
+ ///
+ /// Gets or sets optional plugin ids.
+ ///
+ [Key("optionalPluginIds")] public string[] OptionalPluginIds { get; set; } = Array.Empty();
+ }
+
///
/// Describes one schema exposed through the CultNet schema catalog.
///
diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs
index 668883ea..bb2ab477 100644
--- a/tests/GameCult.Networking.Tests/NetworkingTests.cs
+++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs
@@ -328,6 +328,43 @@ namespace GameCult.Networking.Tests
Assert.That(roundTrip.Entries[0].Put!.Document.RecordKey, Is.EqualTo("player:42"));
}
+ [Test]
+ public void CultNetSchemaMessageSerialization_RoundTrips_VerseCatalogResponse()
+ {
+ var message = new CultMeshVerseCatalogResponseMessage
+ {
+ MessageId = "verses-1",
+ Verses =
+ [
+ new CultMeshVerseDescriptorMessage
+ {
+ VerseId = "aetheria-main",
+ DisplayName = "Aetheria",
+ AuthorityModel = "OperatorCluster",
+ Compatibility = new CultMeshVerseCompatibilityMessage
+ {
+ TransportVersion = "cultmesh.v0",
+ RulesHash = "rules",
+ CompatibleVerseIds = ["aetheria-modded"],
+ RequiredPluginIds = ["core"],
+ OptionalPluginIds = ["skylands"]
+ },
+ DiscoveryEndpoints = ["cultmesh://aetheria.example.test:3075"],
+ AuthorityRuntimeIds = ["runtime-a"],
+ Description = "main branch"
+ }
+ ]
+ };
+
+ var payload = CultNetSchemaMessageSerialization.Serialize(message);
+ var roundTrip = (CultMeshVerseCatalogResponseMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
+
+ Assert.That(roundTrip.MessageId, Is.EqualTo("verses-1"));
+ Assert.That(roundTrip.Verses, Has.Length.EqualTo(1));
+ Assert.That(roundTrip.Verses[0].VerseId, Is.EqualTo("aetheria-main"));
+ Assert.That(roundTrip.Verses[0].Compatibility.RequiredPluginIds, Is.EqualTo(["core"]));
+ }
+
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_SimulationObservation()
{
@@ -792,6 +829,38 @@ namespace GameCult.Networking.Tests
Assert.That(incompatible.CanTransferFrom(aetheria), Is.False);
}
+ [Test]
+ public void CultMeshVerseDiscoveryServer_Creates_FilteredCatalogResponse()
+ {
+ using var catalog = CultMesh.CreateVerseCatalog();
+ var rulesHash = CultMeshVerseDescriptor.ComputeRulesHash("aetheria", "vanilla");
+ catalog.Upsert(new CultMeshVerseDescriptor(
+ "aetheria-main",
+ "Aetheria",
+ CultMeshVerseAuthorityModel.OperatorCluster,
+ new CultMeshVerseCompatibility("cultmesh.v0", rulesHash),
+ discoveryEndpoints: ["cultmesh://aetheria.example.test:3075"],
+ authorityRuntimeIds: ["runtime-a"]));
+ catalog.Upsert(new CultMeshVerseDescriptor(
+ "old-branch",
+ "Old Branch",
+ CultMeshVerseAuthorityModel.PeerToPeer,
+ new CultMeshVerseCompatibility("cultmesh.legacy", rulesHash)));
+ using var server = new Server(new CultCache(), DevelopmentServerSecurity);
+ using var discovery = new CultMeshVerseDiscoveryServer(server, catalog);
+
+ var response = discovery.CreateResponse(new CultMeshVerseCatalogRequestMessage
+ {
+ MessageId = "discover-1",
+ TransportVersion = "cultmesh.v0"
+ });
+
+ Assert.That(response.MessageId, Is.EqualTo("discover-1"));
+ Assert.That(response.Verses, Has.Length.EqualTo(1));
+ Assert.That(response.Verses[0].VerseId, Is.EqualTo("aetheria-main"));
+ Assert.That(response.Verses[0].DiscoveryEndpoints, Is.EqualTo(["cultmesh://aetheria.example.test:3075"]));
+ }
+
[Test]
public async Task CultMeshNode_CanEnable_DurableShardLogs_WithDefaultPath()
{