diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md index c8cb034c..5f2c01e4 100644 --- a/docs/cultnet-transport-parity.md +++ b/docs/cultnet-transport-parity.md @@ -286,8 +286,11 @@ Current progress: TypeScript, Python, Kotlin, and Rust: it parses `rudp://host:port` endpoints, creates RUDP client/server transports from endpoint or peer-card contact hints, and can create a RUDP client from the first peer authorized by the - authority lease catalog for a Verse role. RUDP packet/session behavior remains - owned by `GameCult.Networking`. + authority lease catalog for a Verse role. The facade can also return a + connected RUDP client from a direct endpoint, peer card, or authorized peer + lookup, so schema-message helpers can ride the RUDP pipe without caller-side + handshake ceremony. RUDP packet/session behavior remains owned by + `GameCult.Networking`. - TypeScript now has a `CultNetRudpReconnectLoop` that consumes the shared controller while keeping socket construction caller-owned. A closed RUDP transport schedules the next attempt with the portable policy, opens a fresh diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 6548bf61..bda1c36d 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -30,7 +30,7 @@ CultMesh. | Runtime | Role | Claimed parity | Not claimed | | --- | --- | --- | --- | -| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | +| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | | Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP CultMesh server, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe | | TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack | | Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server | diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index e672f8bd..9cc40ca2 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -224,6 +224,21 @@ namespace GameCult.Mesh public CultNetReconnectPolicy? ReconnectPolicy { get; set; } } + /// + /// Options for CultMesh-branded connected RUDP client helpers. + /// + public sealed class CultMeshRudpClientOptions + { + /// Gets or sets the socket construction and transport profile options. + public CultMeshRudpSocketOptions SocketOptions { get; set; } = new(); + /// Gets or sets the handshake payload. + public byte[] ConnectPayload { get; set; } = Array.Empty(); + /// Gets or sets the handshake timeout. + public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(1); + /// Gets or sets the handshake polling interval. + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(5); + } + /// /// Public CultMesh entrypoints. /// @@ -415,6 +430,82 @@ namespace GameCult.Mesh return CreateRudpClientForPeer(runtimeId, connectionId, peer, options); } + /// + /// Creates and handshakes a CultNet RUDP client transport for a parsed endpoint. + /// + public static CultNetRudpSocketTransportConnection ConnectRudpClient( + string runtimeId, + uint connectionId, + CultMeshRudpEndpoint endpoint, + CultMeshRudpClientOptions? options = null) + { + options ??= new CultMeshRudpClientOptions(); + var client = CreateRudpClient(runtimeId, connectionId, endpoint, options.SocketOptions); + if (!client.ConnectAndWait(options.ConnectPayload, options.ConnectTimeout, options.PollInterval)) + { + client.Dispose(); + throw new TimeoutException($"Timed out waiting for RUDP client {runtimeId} to connect."); + } + + return client; + } + + /// + /// Creates and handshakes a CultNet RUDP client transport for a rudp://host:port endpoint. + /// + public static CultNetRudpSocketTransportConnection ConnectRudpClient( + string runtimeId, + uint connectionId, + string endpoint, + CultMeshRudpClientOptions? options = null) + { + return ConnectRudpClient(runtimeId, connectionId, ParseRudpEndpoint(endpoint), options); + } + + /// + /// Creates and handshakes a CultNet RUDP client transport from a peer-card contact hint. + /// + public static CultNetRudpSocketTransportConnection ConnectRudpClientForPeer( + string runtimeId, + uint connectionId, + CultMeshPeerCard peer, + CultMeshRudpClientOptions? options = null) + { + if (peer == null) throw new ArgumentNullException(nameof(peer)); + var endpoint = peer.Endpoints.FirstOrDefault(value => value.StartsWith("rudp://", StringComparison.OrdinalIgnoreCase)); + if (endpoint == null) + { + throw new InvalidOperationException($"Peer {peer.PeerId} does not advertise a RUDP endpoint."); + } + + return ConnectRudpClient(runtimeId, connectionId, endpoint, options); + } + + /// + /// Creates and handshakes a CultNet RUDP client transport from the first peer authorized for a Verse role. + /// + public static CultNetRudpSocketTransportConnection ConnectRudpClientForAuthorizedPeer( + string runtimeId, + uint connectionId, + CultMeshPeerCatalog peers, + CultMeshAuthorityLeaseCatalog leases, + string verseId, + string role, + string? shardId = null, + DateTimeOffset? at = null, + CultMeshRudpClientOptions? options = null) + { + if (peers == null) throw new ArgumentNullException(nameof(peers)); + if (leases == null) throw new ArgumentNullException(nameof(leases)); + var peer = peers.FirstAuthorized(verseId, role, leases, shardId, at); + if (peer == null) + { + throw new InvalidOperationException($"No authorized RUDP peer for role {role} in Verse {verseId}."); + } + + return ConnectRudpClientForPeer(runtimeId, connectionId, peer, options); + } + /// /// Creates a committer for writing quorum simulation facts to a mesh database. /// diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index f08d3066..6b1a4f31 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -117,7 +117,7 @@ peer lookup with the lease catalog, so contact gossip stays separate from trust. The branded RUDP helpers use the same boundary: ```csharp -using var rudpClient = CultMesh.CreateRudpClientForAuthorizedPeer( +using var rudpClient = CultMesh.ConnectRudpClientForAuthorizedPeer( "aetheria-client", connectionId: 0x10203040, peers, @@ -128,9 +128,12 @@ using var rudpClient = CultMesh.CreateRudpClientForAuthorizedPeer( ``` `CreateRudpServer(...)`, `ParseRudpEndpoint(...)`, -`CreateRudpClient(...)`, and `CreateRudpClientForPeer(...)` delegate to the -CultNet RUDP socket transport. CultMesh owns peer/authority ergonomics; CultNet -owns packet reliability, resend pressure, and channel semantics. +`ConnectRudpClient(...)`, and `ConnectRudpClientForPeer(...)` delegate to the +CultNet RUDP socket transport and return the same schema-message-capable +transport after handshake. `CreateRudpClient(...)` and +`CreateRudpClientForPeer(...)` remain available when a caller intentionally owns +the handshake and polling loop. CultMesh owns peer/authority ergonomics; +CultNet owns packet reliability, resend pressure, and channel semantics. Clients can fetch more peers from known peers: diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 7145d433..6089d331 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -20,6 +20,9 @@ Namespace: `GameCult.Mesh` - `CultMesh.CreateRudpClient(...)` - `CultMesh.CreateRudpClientForPeer(...)` - `CultMesh.CreateRudpClientForAuthorizedPeer(...)` +- `CultMesh.ConnectRudpClient(...)` +- `CultMesh.ConnectRudpClientForPeer(...)` +- `CultMesh.ConnectRudpClientForAuthorizedPeer(...)` - `CultMesh.CreateSimulationFactCommitter(...)` - `CultMesh.CreateGameSession(...)` - `CultMesh.CreateClient(...)` @@ -89,9 +92,12 @@ the world. `CultMesh.ParseRudpEndpoint(...)`, `CreateRudpServer(...)`, `CreateRudpClient(...)`, `CreateRudpClientForPeer(...)`, and `CreateRudpClientForAuthorizedPeer(...)` provide the branded CultMesh path to -the native CultNet RUDP socket transport. These helpers parse endpoint contact -hints and choose authorized peers; `GameCult.Networking` still owns the RUDP -packet codec, session state, resend, fragmentation, and channel semantics. +the native CultNet RUDP socket transport. `ConnectRudpClient(...)`, +`ConnectRudpClientForPeer(...)`, and `ConnectRudpClientForAuthorizedPeer(...)` +also perform the client handshake before returning the same transport. These +helpers parse endpoint contact hints and choose authorized peers; +`GameCult.Networking` still owns the RUDP packet codec, session state, resend, +fragmentation, and channel semantics. ### Shard Authority diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index 60d47d99..bb5cedd0 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -379,6 +379,11 @@ The native RUDP socket path also exposes `CultNetRudpReconnectLoop` for caller-owned game or service loops. The caller reports closure, calls `ReconnectIfDue(nowMs)` from its own scheduler, and the shared `CultNetReconnectController` owns retry attempts, delay, and exhaustion state. +`GameCult.Mesh` adds discovery-shaped helpers such as +`CultMesh.ConnectRudpClientForAuthorizedPeer(...)`, which perform authorized +peer lookup and handshake before returning the same RUDP transport. Packet +semantics, schema-channel send/receive helpers, and reconnect state still live +in `GameCult.Networking`. ## Important Constraints diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index b5efa9db..0be77bed 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -7,6 +7,7 @@ using System.Net; using System.Net.Sockets; using System.Reflection; using System.Text; +using System.Threading; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -2146,7 +2147,18 @@ namespace GameCult.Networking.Tests now.AddSeconds(-1), now.AddSeconds(30))); - using var client = CultMesh.CreateRudpClientForAuthorizedPeer( + using var serverPumpCts = new CancellationTokenSource(); + var serverPump = Task.Run(() => + { + while (!server.Connected && !serverPumpCts.IsCancellationRequested) + { + _ = server.ReceiveOnce(); + server.PollResends(); + Thread.Sleep(TimeSpan.FromMilliseconds(5)); + } + }); + + using var client = CultMesh.ConnectRudpClientForAuthorizedPeer( "csharp-cultmesh-rudp-client", connectionId, peers, @@ -2154,22 +2166,31 @@ namespace GameCult.Networking.Tests "local", "schema", at: now, - options: new CultMeshRudpSocketOptions + options: new CultMeshRudpClientOptions { - ResendDelayMs = 25, - MaxFragmentBytes = 1024, - MaxPendingReliablePackets = 16 + ConnectPayload = Encoding.UTF8.GetBytes("join"), + SocketOptions = new CultMeshRudpSocketOptions + { + ResendDelayMs = 25, + MaxFragmentBytes = 1024, + MaxPendingReliablePackets = 16 + } }); + serverPumpCts.Cancel(); + Assert.That(serverPump.Wait(TimeSpan.FromSeconds(1)), Is.True); - client.Connect(Encoding.UTF8.GetBytes("join")); - PumpRudpHandshake(client, server); Assert.That(client.Connected, Is.True); Assert.That(server.Connected, Is.True); - client.SendSchema(Encoding.UTF8.GetBytes("client-state")); - var serverFrame = ReceiveRudpFrame(server); - Assert.That(serverFrame.ChannelId, Is.EqualTo("schema")); - Assert.That(Encoding.UTF8.GetString(serverFrame.Payload), Is.EqualTo("client-state")); + client.SendSchemaMessage(new CultNetSchemaCatalogRequestMessage + { + MessageId = "csharp-cultmesh-rudp-schema-catalog", + Kinds = ["document_payload"], + IncludeSchemaJson = true + }); + var request = ReceiveRudpSchemaMessage(server); + Assert.That(request.MessageId, Is.EqualTo("csharp-cultmesh-rudp-schema-catalog")); + Assert.That(request.Kinds, Is.EqualTo(new[] { "document_payload" })); Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp")); Assert.That(client.Profile.Transports[0].Protocol, Is.EqualTo("rudp")); }