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